블로그 선택

포스팅할 블로그를 선택하세요.

스타일러프로 API 블로그 자동 포스팅 v5.0

by 유튜버 디지털노마드

이미지 생성까지 자동 (이미지 무료, API 키)

🔍 Google Search API 설정 (선택사항)

하루 100회 무료 검색 가능

💡 Google Search API 사용 안내:
• 최신 정보 검색으로 AI 글의 정확도 향상
• 하루 100회 무료 (초과 시 $5/1000회)
• 설정하지 않아도 기본 AI 포스팅 가능

설정 정보는 어디서 찾나요?

Google OAuth 클라이언트 ID:
Google Cloud Console에서 `Blogger API` 활성화 후, '사용자 인증 정보' > '+ 만들기' > 'OAuth 클라이언트 ID' (유형: 웹 애플리케이션)를 선택하여 생성합니다. **'승인된 자바스크립트 원본'**에 이 앱을 실행할 URL(예: `http://localhost:8080`)을 반드시 추가해야 합니다.

Gemini API 키:
Google AI Studio에서 발급받습니다. 텍스트 생성에만 사용되며, 이미지는 Pollinations.ai를 통해 무료로 생성됩니다.

Google Search API 키 & 검색 엔진 ID:
1. Google Cloud Console에서 'Custom Search API' 활성화 후 API 키 발급
2. Programmable Search Engine에서 검색 엔진 생성 후 CX ID 복사

2. 도입부

도입부 첫 문장으로 독자의 관심을 끌어요. 두 번째 문장에서 주제를 소개해요. 세 번째 문장으로 이어가요.

네 번째 문장에서 배경을 설명해요. 다섯 번째 문장으로 중요성을 강조해요.

3. 본문 섹션 구조 - 회색 박스 제목 + h3/h4 소제목 + 본문 [섹션 1-6은 모두 콘텐츠 관련 주제로 작성]

1. 첫 번째 주제 제목 (콘텐츠 관련)

1.1 세부 주제

내용 설명 첫 번째 문장이에요. 두 번째 문장으로 보충해요. 세 번째 문장으로 예시를 들어요.

추가 정보를 제공해요. 마무리 문장이에요.

[섹션 2-5도 동일한 구조로 콘텐츠 관련 내용 작성]

6. 여섯 번째 주제 제목 (콘텐츠 관련)

6.1 마지막 콘텐츠 주제

여섯 번째 섹션의 내용이에요. 이 섹션도 FAQ가 아닌 콘텐츠 관련 주제예요.

추가 설명을 이어가요. 중요한 정보를 담아요.

4. FAQ 섹션 - 7번째 섹션으로 독립

7. 자주 묻는 질문

Q1. 첫 번째 질문은 무엇인가요?
간결한 답변을 제공해요. 핵심 정보만 담아 설명해요.
Q2. 두 번째 질문은 무엇인가요?
실용적인 답변을 드려요. 구체적인 해결책을 제시해요.
Q3. 세 번째 질문은 무엇인가요?
명확한 답변을 제공해요. 이해하기 쉽게 설명해요.
Q4. 네 번째 질문은 무엇인가요?
도움이 되는 정보를 드려요. 추가 팁도 함께 제공해요.
Q5. 다섯 번째 질문은 무엇인가요?
자주 나오는 궁금증을 해결해요. 실제 사례를 들어 설명해요.
Q6. 여섯 번째 질문은 무엇인가요?
종합적인 답변을 제공해요. 전체 내용을 정리해드려요.
5. 면책조항과 요약

⚠️ 면책조항

이 글은 일반적인 정보 제공 목적으로 작성되었으며, 전문가의 조언을 대체할 수 없어요.

📌 요약

• 첫 번째 핵심 포인트
• 두 번째 핵심 포인트
• 세 번째 핵심 포인트
• 네 번째 핵심 포인트
• 다섯 번째 핵심 포인트

핵심 작성 규칙: ★ 섹션 구조 (필수 준수): - 섹션 1-6: 모두 콘텐츠 관련 주제로만 작성 - 섹션 7: 자주 묻는 질문 (FAQ)만 - 절대로 섹션 6에 FAQ를 넣지 말 것 - 총 7개 섹션 (콘텐츠 6개 + FAQ 1개) 1. 구조: - 섹션 제목: 연한 회색 박스(#f5f5f5) 안에 검은색 텍스트 - 모든 제목(h2, h3, h4): 검은색(#000000) - 제목 내 링크도 검은색 유지 - 깔끔한 계층 구조 유지 2. 스타일: - 모든 제목 검은색 통일 - 섹션 헤더만 회색 박스 배경 - 모든 본문 font-size: 16px - 본문 내 링크만 파란색, 제목은 검은색 3. 줄바꿈: - 2-3문장마다

사용 - 문단 간 적절한 간격 유지 - 가독성 중심으로 구성 4. 내용: - "해요", "이에요" 어체 사용 - 중복 없이 논리적 흐름 - 실용적이고 구체적인 정보 - FAQ는 정확히 6개, 섹션 7에만 5. 금지사항: - ** 또는 * 표시 금지 - 강조 박스 사용 금지 - 화려한 디자인 요소 금지 - h1 태그 사용 금지 - 섹션 6에 FAQ 넣기 금지 위 가이드라인을 정확히 따라 심플하고 읽기 쉬운 블로그 글을 작성해주세요.`; // 수정된 이미지 생성 함수들 (사람/얼굴 제거 강화) async function translateToEnglish(koreanText) { if (!GEMINI_API_KEY) { console.warn('Gemini API 키가 없어 원문 프롬프트로 이미지를 생성합니다.'); return koreanText; } try { const translatePrompt = ` You are an image prompt cleaner and translator. 1) Translate the following Korean blog image description into natural English. 2) Focus on OBJECTS, PRODUCTS, INTERIORS, LANDSCAPES or FOOD ONLY. 3) NEVER include: person, people, man, woman, girl, boy, child, face, selfie, portrait, body, hand, arm, leg, skin, model, character, cartoon, anime, illustration. 4) Remove any words related to humans or body parts. 5) Write a single English sentence, no quotes. Text: "${koreanText}" `; const body = { contents: [{ parts: [{ text: translatePrompt }] }] }; const response = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } ); if (!response.ok) { console.error('번역 실패:', await response.text()); return koreanText; } const data = await response.json(); let english = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || koreanText; // 혹시 남아 있을지 모를 사람 관련 단어 한 번 더 필터링 const peopleKeywords = [ 'person','people','man','woman','boy','girl','child','face', 'selfie','portrait','body','human','hands','hand','arm','leg','skin', 'character','figure' ]; let cleaned = english; for (const kw of peopleKeywords) { const re = new RegExp(`\\b${kw}s?\\b`, 'gi'); cleaned = cleaned.replace(re, ''); } cleaned = cleaned.replace(/\s+/g, ' ').trim(); if (!cleaned) { cleaned = 'high quality product, detail shot, no people, studio background'; } return cleaned; } catch (error) { console.error('translateToEnglish error:', error); return koreanText; } } async function generatePollinationsImage(prompt) { try { const hasKorean = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(prompt); let basePrompt = prompt; if (hasKorean) { basePrompt = await translateToEnglish(prompt); } // 사람 관련 단어 강제 제거 const peopleKeywords = [ 'person','people','man','woman','boy','girl','child','face', 'selfie','portrait','body','human','hands','hand','arm','leg','skin', 'character','figure','model' ]; let cleaned = basePrompt; for (const kw of peopleKeywords) { const re = new RegExp(`\\b${kw}s?\\b`, 'gi'); cleaned = cleaned.replace(re, ''); } cleaned = cleaned.replace(/\s+/g, ' ').trim(); if (cleaned.length < 10) { cleaned += ' high quality product photo, no people, studio lighting'; } const positiveEnhancements = [ 'high quality photography', 'sharp focus', 'professional lighting', 'detailed texture', 'no people', 'no humans', 'clean background' ]; const negativePrompts = [ 'person','people','man','woman','boy','girl','child','face', 'selfie','portrait','body','human','hands','hand','arms','legs','skin', 'cartoon','anime','illustration','drawing','3d render','cgi', 'low quality','blurry','deformed','disfigured' ]; const finalPrompt = `${cleaned}, ${positiveEnhancements.join(', ')}`; const encodedPrompt = encodeURIComponent(finalPrompt); const negative = encodeURIComponent(negativePrompts.join(', ')); const seed = Date.now() + Math.floor(Math.random() * 10000); // flux + no_people=true 강제 적용 const imageUrl = `https://image.pollinations.ai/prompt/${encodedPrompt}` + `?model=flux&width=1024&height=768&nologo=true` + `&seed=${seed}&safe=true&no_people=true&negative=${negative}`; return imageUrl; } catch (error) { console.error('generatePollinationsImage error:', error); const fallbackPrompt = encodeURIComponent( 'high quality product photo, modern interior, detailed texture, no people, no humans, studio lighting' ); return `https://image.pollinations.ai/prompt/${fallbackPrompt}?model=flux&width=1024&height=768&nologo=true&safe=true&no_people=true`; } } // 수정된 대량 예약 관련 함수들 async function handleGenerateTopics() { const manualInput = document.getElementById('manual-topics-input'); const loader = document.getElementById('topic-ideas-loader'); const topicSelectionContainer = document.getElementById('topic-selection-container'); const bulkScheduleConfig = document.getElementById('bulk-schedule-config'); if (!manualInput) return; const raw = manualInput.value .split('\n') .map(line => line.trim()) .filter(line => line.length > 0); if (raw.length === 0) { showStatusMessage('한 줄에 하나씩 제목을 먼저 입력해주세요.', 'error'); manualInput.focus(); return; } loader.classList.remove('hidden'); topicSelectionContainer.classList.add('hidden'); bulkScheduleConfig.classList.add('hidden'); try { // 중복 제거 const uniqueTopics = [...new Set(raw)]; renderTopicIdeas(uniqueTopics); topicSelectionContainer.classList.remove('hidden'); bulkScheduleConfig.classList.remove('hidden'); showStatusMessage(`총 ${uniqueTopics.length}개의 제목이 준비되었습니다. 사용할 제목을 선택해주세요.`, 'success'); } catch (error) { console.error(error); showStatusMessage('제목 리스트를 불러오는 중 오류가 발생했습니다.', 'error'); } finally { loader.classList.add('hidden'); } } function renderTopicIdeas(topics) { const listEl = document.getElementById('topic-ideas-list'); const totalTopicCountEl = document.getElementById('total-topic-count'); if (totalTopicCountEl) { totalTopicCountEl.textContent = topics.length; } if (!listEl) return; listEl.innerHTML = topics.map((topic, index) => { const safeId = `topic-${index}`; const safeValue = topic.replace(/"/g, '"'); return `
  • `; }).join(''); // 체크박스 이벤트 리스너 추가 listEl.querySelectorAll('.topic-checkbox').forEach(checkbox => { checkbox.addEventListener('change', function() { const listItem = this.closest('li'); if (this.checked) { listItem.classList.add('selected'); } else { listItem.classList.remove('selected'); } updateSelectedTopicCount(); validateScheduleSettings(); updateBulkDateInfo(); updateSearchQuotaDisplay(); }); }); updateSelectedTopicCount(); lucide.createIcons(); } // 나머지 모든 함수들은 원본 코드와 동일하게 유지 // (여기서부터는 원본 코드의 나머지 부분을 그대로 복사) // 멀티 블로그 RSS 관련 함수들 window.addBlogInput = function() { if (currentBlogCount >= 4) { showStatusMessage('최대 4개의 블로그만 추가할 수 있습니다.', 'error'); return; } currentBlogCount++; const container = document.getElementById('blog-url-inputs-container'); const inputGroup = document.createElement('div'); inputGroup.className = 'multi-blog-input-group'; inputGroup.innerHTML = ` `; container.appendChild(inputGroup); lucide.createIcons(); // 첫 번째 입력창의 삭제 버튼 표시 const firstRemoveBtn = container.querySelector('.multi-blog-input-group:first-child .remove-blog-url-btn'); if (firstRemoveBtn && currentBlogCount > 1) { firstRemoveBtn.classList.remove('hidden'); } // 4개가 되면 추가 버튼 숨기기 if (currentBlogCount >= 4) { document.getElementById('add-blog-url-btn').style.display = 'none'; } } window.removeBlogInput = function(index) { const container = document.getElementById('blog-url-inputs-container'); const inputGroups = container.querySelectorAll('.multi-blog-input-group'); if (inputGroups.length <= 1) { showStatusMessage('최소 1개의 블로그는 유지해야 합니다.', 'error'); return; } // 해당 입력창 제거 inputGroups.forEach((group, i) => { const input = group.querySelector('.multi-blog-input'); if (input && parseInt(input.dataset.blogIndex) === index) { group.remove(); } }); currentBlogCount--; // 인덱스 재정렬 const newInputGroups = container.querySelectorAll('.multi-blog-input-group'); newInputGroups.forEach((group, i) => { const input = group.querySelector('.multi-blog-input'); const removeBtn = group.querySelector('.remove-blog-url-btn'); if (input) { input.dataset.blogIndex = i; input.placeholder = `https://example${i + 1}.blogspot.com`; } if (removeBtn) { removeBtn.setAttribute('onclick', `removeBlogInput(${i})`); } }); // 1개만 남았을 때 삭제 버튼 숨기기 if (newInputGroups.length === 1) { const lastRemoveBtn = newInputGroups[0].querySelector('.remove-blog-url-btn'); if (lastRemoveBtn) { lastRemoveBtn.classList.add('hidden'); } } // 추가 버튼 다시 표시 if (currentBlogCount < 4) { document.getElementById('add-blog-url-btn').style.display = 'inline-flex'; } } // 블로그 URL 복사 함수 window.copyBlogUrl = function() { const blogUrlText = document.getElementById('blog-url-text'); const copyBtn = document.getElementById('copy-url-btn'); if (blogUrlText && selectedBlogUrl) { navigator.clipboard.writeText(selectedBlogUrl).then(() => { const originalHtml = copyBtn.innerHTML; copyBtn.innerHTML = '✓'; copyBtn.style.backgroundColor = 'rgba(34, 197, 94, 0.3)'; setTimeout(() => { copyBtn.innerHTML = originalHtml; copyBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)'; lucide.createIcons(); }, 2000); showStatusMessage('블로그 주소가 클립보드에 복사되었습니다!', 'success'); }).catch(err => { showStatusMessage('복사 실패: ' + err.message, 'error'); }); } } // 제목 및 버튼 편집 관련 함수들 function setupTitleAndButtonEditing() { const editMainTitleBtn = document.getElementById('edit-main-title-btn'); const editButtonsBtn = document.getElementById('edit-buttons-btn'); const mainTitleInput = document.getElementById('generated-main-title'); const buttonEditContainer = document.getElementById('button-edit-container'); if (editMainTitleBtn) { editMainTitleBtn.addEventListener('click', () => { if (!isEditingMainTitle) { mainTitleInput.readOnly = false; mainTitleInput.classList.remove('bg-gray-100'); mainTitleInput.classList.add('bg-white'); mainTitleInput.focus(); editMainTitleBtn.innerHTML = ' 저장'; isEditingMainTitle = true; updateMainTitleCharCount(); } else { mainTitleInput.readOnly = true; mainTitleInput.classList.add('bg-gray-100'); mainTitleInput.classList.remove('bg-white'); editMainTitleBtn.innerHTML = ' 수정'; isEditingMainTitle = false; generatedMainTitle = mainTitleInput.value; showStatusMessage('제목이 저장되었습니다.', 'success'); } lucide.createIcons(); }); } if (editButtonsBtn) { editButtonsBtn.addEventListener('click', () => { if (!isEditingButtons) { buttonEditContainer.classList.remove('hidden'); editButtonsBtn.innerHTML = ' 저장'; isEditingButtons = true; // 현재 버튼 텍스트를 입력 필드에 표시 generatedButtonTexts.forEach((text, idx) => { const input = document.getElementById(`button-text-${idx + 1}`); if (input) { input.value = text; updateButtonCharCount(idx + 1); } }); } else { buttonEditContainer.classList.add('hidden'); editButtonsBtn.innerHTML = ' 수정'; isEditingButtons = false; // 수정된 버튼 텍스트 저장 for (let i = 1; i <= 4; i++) { const input = document.getElementById(`button-text-${i}`); if (input && generatedButtonTexts[i - 1]) { generatedButtonTexts[i - 1] = input.value; } } // 미리보기 업데이트 updateButtonPreview(); showStatusMessage('버튼 텍스트가 저장되었습니다.', 'success'); } lucide.createIcons(); }); } // 제목 입력 시 글자수 업데이트 if (mainTitleInput) { mainTitleInput.addEventListener('input', updateMainTitleCharCount); } // 버튼 텍스트 입력 시 글자수 업데이트 for (let i = 1; i <= 4; i++) { const input = document.getElementById(`button-text-${i}`); if (input) { input.addEventListener('input', () => updateButtonCharCount(i)); } } } function updateMainTitleCharCount() { const mainTitleInput = document.getElementById('generated-main-title'); const charCountEl = document.getElementById('main-title-char-count'); if (mainTitleInput && charCountEl) { const length = mainTitleInput.value.length; charCountEl.textContent = `${length}자`; if (length === 0) { charCountEl.className = 'char-count-display warning'; } else if (length > 50) { charCountEl.className = 'char-count-display warning'; } else { charCountEl.className = 'char-count-display success'; } } } function updateButtonCharCount(buttonNum) { const input = document.getElementById(`button-text-${buttonNum}`); const charCountEl = input?.parentElement?.querySelector('.char-count-display'); if (input && charCountEl) { const length = input.value.length; charCountEl.textContent = `${length}/30`; if (length === 0 || length > 25) { charCountEl.className = 'char-count-display text-xs warning'; } else { charCountEl.className = 'char-count-display text-xs'; } } } function updateButtonPreview() { const previewButtons = document.getElementById('preview-buttons'); if (!previewButtons || selectedRssPosts.length === 0) return; previewButtons.innerHTML = generatedButtonTexts.map((text, idx) => ` ${text} `).join(''); } // 멀티 블로그 RSS 가져오기 async function handleFetchMultiRss() { const blogInputs = document.querySelectorAll('.multi-blog-input'); const blogUrls = []; blogInputs.forEach(input => { const url = normalizeUrl(input.value.trim()); if (url) { blogUrls.push({ url, index: parseInt(input.dataset.blogIndex) }); } }); if (blogUrls.length === 0) { showStatusMessage('최소 1개 이상의 블로그 주소를 입력해주세요.', 'error'); return; } const fetchButton = document.getElementById('fetch-multi-rss-button'); fetchButton.disabled = true; fetchButton.innerHTML = ' 로딩 중...'; multiBlogData = []; multiBlogPosts = {}; selectedMultiBlogPosts = []; totalSelectedPosts = 0; try { showStatusMessage(`${blogUrls.length}개 블로그에서 RSS 피드를 가져오는 중...`, 'info'); const fetchPromises = blogUrls.map(async (blogInfo) => { const rssUrls = generateRssUrls(blogInfo.url); let posts = []; for (const rssUrl of rssUrls) { for (const proxyUrl of corsProxies) { try { const rssData = await fetchRssWithProxy(rssUrl, proxyUrl); posts = parseRss(rssData); if (posts.length > 0) break; } catch (error) { continue; } } if (posts.length > 0) break; } return { url: blogInfo.url, index: blogInfo.index, posts: posts }; }); const results = await Promise.all(fetchPromises); let totalPosts = 0; results.forEach(result => { if (result.posts.length > 0) { multiBlogData.push(result); multiBlogPosts[result.index] = result.posts; totalPosts += result.posts.length; } }); if (multiBlogData.length === 0) { throw new Error('RSS 피드를 가져올 수 없습니다.'); } showStatusMessage(`✅ ${multiBlogData.length}개 블로그에서 총 ${totalPosts}개의 포스트를 가져왔습니다!`, 'success'); displayMultiBlogPosts(); } catch (error) { console.error('멀티 RSS 가져오기 실패:', error); showStatusMessage('RSS 피드를 가져올 수 없습니다. 올바른 블로그 주소를 입력해주세요.', 'error'); } finally { fetchButton.disabled = false; fetchButton.innerHTML = ' 모든 블로그 RSS 가져오기'; lucide.createIcons(); } } // 멀티 블로그 포스트 표시 function displayMultiBlogPosts() { const container = document.getElementById('multi-blog-rss-container'); const sectionsContainer = document.getElementById('multi-blog-sections'); container.classList.remove('hidden'); sectionsContainer.innerHTML = ''; multiBlogData.forEach((blogData, blogIndex) => { const section = document.createElement('div'); section.className = 'blog-rss-section'; section.dataset.blogUrl = blogData.url; section.dataset.blogIndex = blogData.index; const headerHtml = `
    ${blogData.url.replace(/https?:\/\//, '').substring(0, 30)}... ${blogData.posts.length}개
    `; const postsHtml = `
    ${blogData.posts.slice(0, 25).map((post, postIndex) => { const dateStr = post.pubDate ? new Date(post.pubDate).toLocaleDateString('ko-KR') : ''; return `
    `; }).join('')}
    `; section.innerHTML = headerHtml + postsHtml; sectionsContainer.appendChild(section); }); // 체크박스 이벤트 리스너 추가 sectionsContainer.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { checkbox.addEventListener('change', handleMultiBlogPostSelection); }); updateMultiSelectedPostsCount(); } // 멀티 블로그 포스트 선택 처리 function handleMultiBlogPostSelection(e) { const checkbox = e.target; if (checkbox.checked) { // 이미 4개 선택되어 있으면 추가 선택 불가 if (totalSelectedPosts >= 4) { checkbox.checked = false; showStatusMessage('최대 4개까지만 선택할 수 있습니다.', 'warn'); return; } const postItem = checkbox.closest('.multi-rss-post-item'); postItem.classList.add('selected'); // 선택된 포스트 추가 const postData = { blogUrl: checkbox.dataset.blogUrl, blogIndex: checkbox.dataset.blogIndex, title: checkbox.dataset.title, link: checkbox.dataset.link }; selectedMultiBlogPosts.push(postData); totalSelectedPosts++; } else { const postItem = checkbox.closest('.multi-rss-post-item'); postItem.classList.remove('selected'); // 선택 해제 selectedMultiBlogPosts = selectedMultiBlogPosts.filter(p => !(p.link === checkbox.dataset.link && p.blogUrl === checkbox.dataset.blogUrl) ); totalSelectedPosts--; } updateMultiSelectedPostsCount(); updateSelectedPostsSummary(); // 4개 선택 시 처리 if (totalSelectedPosts === 4) { selectedRssPosts = selectedMultiBlogPosts.map(p => ({ title: p.title, link: p.link })); generateMainTitle(); generateButtonTexts(); document.getElementById('internal-link-config').classList.remove('hidden'); document.getElementById('internal-char-count-info').classList.remove('hidden'); setupTitleAndButtonEditing(); // 선택 정보 표시 const selectionInfo = document.getElementById('multi-blog-selection-info'); if (selectionInfo) { const blogCounts = {}; selectedMultiBlogPosts.forEach(post => { const shortUrl = post.blogUrl.replace(/https?:\/\//, '').substring(0, 20); blogCounts[shortUrl] = (blogCounts[shortUrl] || 0) + 1; }); const summaryText = Object.entries(blogCounts) .map(([url, count]) => `${url}... (${count}개)`) .join(', '); selectionInfo.innerHTML = ` ✅ 총 4개 포스트 선택 완료!
    블로그별 선택: ${summaryText} `; selectionInfo.classList.remove('hidden'); } } else { document.getElementById('internal-link-config').classList.add('hidden'); document.getElementById('internal-char-count-info').classList.add('hidden'); document.getElementById('multi-blog-selection-info').classList.add('hidden'); } // 블로그 섹션 하이라이트 업데이트 updateBlogSectionHighlights(); } // 블로그 섹션 하이라이트 업데이트 function updateBlogSectionHighlights() { const blogSections = document.querySelectorAll('.blog-rss-section'); blogSections.forEach(section => { const hasSelection = section.querySelector('input[type="checkbox"]:checked'); if (hasSelection) { section.classList.add('has-posts'); } else { section.classList.remove('has-posts'); } }); } // 선택된 포스트 요약 표시 function updateSelectedPostsSummary() { const summaryEl = document.getElementById('selected-posts-summary'); if (!summaryEl) return; if (totalSelectedPosts > 0) { const summaryHtml = ` 📋 선택된 포스트 (${totalSelectedPosts}/4):
    ${selectedMultiBlogPosts.map((post, idx) => `${idx + 1}. ${post.title.substring(0, 30)}...` ).join('
    ')} `; summaryEl.innerHTML = summaryHtml; summaryEl.classList.remove('hidden'); } else { summaryEl.classList.add('hidden'); } } // 멀티 선택 카운트 업데이트 function updateMultiSelectedPostsCount() { const countElement = document.getElementById('multi-selected-posts-count'); if (countElement) { countElement.textContent = `${totalSelectedPosts}/4 선택됨`; countElement.className = totalSelectedPosts === 4 ? 'text-sm text-green-600 font-bold' : 'text-sm text-gray-600'; } // 상태 메시지 업데이트 const statusEl = document.getElementById('multi-blog-status'); if (statusEl) { if (totalSelectedPosts === 0) { statusEl.innerHTML = '블로그 개수에 상관없이 총 4개의 포스트를 선택해주세요.'; statusEl.classList.remove('hidden'); } else if (totalSelectedPosts < 4) { const remaining = 4 - totalSelectedPosts; statusEl.innerHTML = `${remaining}개 더 선택해주세요. (현재 ${totalSelectedPosts}/4)`; statusEl.classList.remove('hidden'); } else { statusEl.classList.add('hidden'); } } } // 추가 함수들 function handleSelectAllTopics() { const topicIdeasList = document.getElementById('topic-ideas-list'); if (!topicIdeasList) return; const checkboxes = topicIdeasList.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(checkbox => { checkbox.checked = true; const listItem = checkbox.closest('li'); if (listItem) { listItem.classList.add('selected'); } }); updateSelectedTopicCount(); validateScheduleSettings(); updateBulkDateInfo(); updateSearchQuotaDisplay(); } function handleDeselectAllTopics() { const topicIdeasList = document.getElementById('topic-ideas-list'); if (!topicIdeasList) return; const checkboxes = topicIdeasList.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(checkbox => { checkbox.checked = false; const listItem = checkbox.closest('li'); if (listItem) { listItem.classList.remove('selected'); } }); updateSelectedTopicCount(); validateScheduleSettings(); updateBulkDateInfo(); updateSearchQuotaDisplay(); } function validateScheduleSettings() { const topicIdeasList = document.getElementById('topic-ideas-list'); const scheduleTimesInput = document.getElementById('schedule-times-input'); const scheduleDaysInput = document.getElementById('schedule-days-input'); const validationMessageEl = document.getElementById('schedule-validation-message'); const calculationDisplayEl = document.getElementById('schedule-calculation-display'); if (!topicIdeasList || !scheduleTimesInput || !scheduleDaysInput) return; const selectedCheckboxes = topicIdeasList.querySelectorAll('input[type="checkbox"]:checked'); const selectedCount = selectedCheckboxes.length; const timesText = scheduleTimesInput.value.trim(); const daysValue = scheduleDaysInput.value.trim(); if (selectedCount === 0) { if (validationMessageEl) validationMessageEl.innerHTML = ''; if (calculationDisplayEl) calculationDisplayEl.innerHTML = ''; return; } const times = timesText.split(',').map(t => t.trim()).filter(Boolean); const days = parseInt(daysValue, 10); if (times.length === 0 || isNaN(days) || days < 1) { if (validationMessageEl) validationMessageEl.innerHTML = ''; if (calculationDisplayEl) calculationDisplayEl.innerHTML = ''; return; } const postsPerDay = times.length; const totalSlots = postsPerDay * days; const exactDaysNeeded = Math.ceil(selectedCount / postsPerDay); let calculationHtml = `
    📊 계산: ${postsPerDay}개/일 × ${days}일 = ${totalSlots}개 슬롯 | 선택한 주제: ${selectedCount}개 ${totalSlots !== selectedCount ? `
    ➜ 필요한 일수: ${exactDaysNeeded}일` : ''}
    `; if (calculationDisplayEl) { calculationDisplayEl.innerHTML = calculationHtml; } let validationHtml = ''; if (totalSlots === selectedCount) { validationHtml = `
    ✅ 완벽한 매칭! ${selectedCount}개 주제가 ${days}일 동안 정확히 예약됩니다.
    `; } else if (totalSlots < selectedCount) { const shortage = selectedCount - totalSlots; validationHtml = `
    ❌ ${shortage}개 주제가 예약되지 않습니다. ${exactDaysNeeded}일로 변경하세요.
    `; } else { const excess = totalSlots - selectedCount; validationHtml = `
    ⚠️ ${excess}개의 빈 슬롯이 발생합니다. ${exactDaysNeeded}일로 변경하세요.
    `; } if (validationMessageEl) { validationMessageEl.innerHTML = validationHtml; lucide.createIcons(); } } function updateBulkDateInfo() { const bulkStartDateInput = document.getElementById('bulk-start-date-input'); const bulkDateInfo = document.getElementById('bulk-date-info'); const bulkDateRange = document.getElementById('bulk-date-range'); const scheduleDaysInput = document.getElementById('schedule-days-input'); const scheduleTimesInput = document.getElementById('schedule-times-input'); const topicIdeasList = document.getElementById('topic-ideas-list'); if (!bulkStartDateInput || !bulkDateInfo || !bulkDateRange) return; const startDate = bulkStartDateInput.value; const days = parseInt(scheduleDaysInput?.value || 1, 10); if (!startDate || isNaN(days)) { bulkDateInfo.classList.add('hidden'); return; } const start = new Date(startDate); const end = new Date(startDate); end.setDate(end.getDate() + days - 1); const times = scheduleTimesInput?.value.split(',').map(t => t.trim()).filter(Boolean) || []; const selectedCount = topicIdeasList ? topicIdeasList.querySelectorAll('input[type="checkbox"]:checked').length : 0; const postsPerDay = times.length; const totalSlots = postsPerDay * days; let infoHtml = ` • 시작: ${start.toLocaleDateString('ko-KR')}
    • 종료: ${end.toLocaleDateString('ko-KR')}
    • 기간: ${days}일
    `; if (postsPerDay > 0) { infoHtml += ` • 하루 발행: ${postsPerDay}회 (${times.join(', ')})
    • 총 슬롯: ${totalSlots}개
    `; if (selectedCount > 0) { infoHtml += `• 선택한 주제: ${selectedCount}개 `; if (totalSlots === selectedCount) { infoHtml += `✅ 완벽 매칭`; } else if (totalSlots < selectedCount) { infoHtml += `❌ ${selectedCount - totalSlots}개 부족`; } else { infoHtml += `⚠️ ${totalSlots - selectedCount}개 초과`; } } } bulkDateRange.innerHTML = infoHtml; bulkDateInfo.classList.remove('hidden'); } function setDefaultDates() { const today = new Date(); const bulkStartDateInput = document.getElementById('bulk-start-date-input'); if (bulkStartDateInput && !bulkStartDateInput.value) { const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); bulkStartDateInput.value = `${year}-${month}-${day}`; } } async function fetchScheduledPostsWithTimes() { try { const data = await fetchWithAuth( `https://www.googleapis.com/blogger/v3/blogs/${selectedBlogId}/posts?status=SCHEDULED&maxResults=500&fields=items(title,published)` ); scheduledPostTitles.clear(); scheduledPostTimes.clear(); (data.items || []).forEach(post => { scheduledPostTitles.add(post.title.toLowerCase()); const publishDate = new Date(post.published); const dateKey = publishDate.toDateString(); const timeKey = `${String(publishDate.getHours()).padStart(2, '0')}:${String(publishDate.getMinutes()).padStart(2, '0')}`; if (!scheduledPostTimes.has(dateKey)) { scheduledPostTimes.set(dateKey, new Set()); } scheduledPostTimes.get(dateKey).add(timeKey); }); return { titles: scheduledPostTitles, times: scheduledPostTimes }; } catch (error) { console.error('예약된 글 시간 정보 가져오기 실패:', error); return { titles: new Set(), times: new Map() }; } } async function createSmartSchedule(topics, times, days, shouldGenerateImages, useGoogleSearch, startDateStr) { const bulkQueue = []; const skippedSlots = []; const processedTopics = []; await fetchScheduledPostsWithTimes(); const startDate = new Date(startDateStr); let topicIndex = 0; for (let dayOffset = 0; dayOffset < days && topicIndex < topics.length; dayOffset++) { const currentDate = new Date(startDate); currentDate.setDate(currentDate.getDate() + dayOffset); const dateKey = currentDate.toDateString(); for (const timeStr of times) { if (topicIndex >= topics.length) break; const [hour, minute] = timeStr.split(':').map(Number); const publishDate = new Date(currentDate); publishDate.setHours(hour, minute, 0, 0); const now = new Date(); if (publishDate <= now) { skippedSlots.push({ date: currentDate.toLocaleDateString('ko-KR'), time: timeStr, reason: '과거 시간' }); continue; } if (scheduledPostTimes.has(dateKey) && scheduledPostTimes.get(dateKey).has(timeStr)) { skippedSlots.push({ date: currentDate.toLocaleDateString('ko-KR'), time: timeStr, reason: '기존 예약과 충돌' }); continue; } const canUseSearch = useGoogleSearch && (searchUsageToday.count + bulkQueue.length < SEARCH_DAILY_LIMIT); bulkQueue.push({ topic: topics[topicIndex], publishDate: publishDate, generateImage: shouldGenerateImages, useGoogleSearch: canUseSearch }); processedTopics.push(topics[topicIndex]); topicIndex++; } } return { bulkQueue, skippedSlots, processedTopics }; } // RSS URL 정규화 function normalizeUrl(url) { if (!url) return ''; url = url.trim(); if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://' + url; } return url.replace(/\/$/, ''); } // RSS URL 생성 function generateRssUrls(blogUrl) { const urls = []; if (blogUrl.includes('blogspot.com')) { urls.push(`${blogUrl}/feeds/posts/default`); urls.push(`${blogUrl}/feeds/posts/default?alt=rss`); } else if (blogUrl.includes('tistory.com')) { urls.push(`${blogUrl}/rss`); } else if (blogUrl.includes('wordpress.com')) { urls.push(`${blogUrl}/feed/`); } else if (blogUrl.includes('blog.naver.com')) { const match = blogUrl.match(/blog\.naver\.com\/(.+)/); if (match) { urls.push(`https://rss.blog.naver.com/${match[1]}.xml`); } } else { urls.push(`${blogUrl}/rss`); urls.push(`${blogUrl}/feed`); urls.push(`${blogUrl}/rss.xml`); } return urls; } // RSS 파싱 function parseRss(xmlText) { try { const parser = new DOMParser(); const doc = parser.parseFromString(xmlText, 'text/xml'); if (doc.querySelector('parsererror')) { throw new Error('XML 파싱 오류'); } let items = doc.querySelectorAll('item'); let isAtom = false; if (items.length === 0) { items = doc.querySelectorAll('entry'); isAtom = true; } const posts = []; items.forEach((item, index) => { if (index >= 25) return; let title, link, pubDate; if (isAtom) { title = item.querySelector('title')?.textContent?.trim() || `포스트 ${index + 1}`; const linkElement = item.querySelector('link[rel="alternate"]') || item.querySelector('link'); link = linkElement?.getAttribute('href') || linkElement?.textContent || ''; pubDate = item.querySelector('published')?.textContent || item.querySelector('updated')?.textContent || ''; } else { title = item.querySelector('title')?.textContent?.trim() || `포스트 ${index + 1}`; link = item.querySelector('link')?.textContent?.trim() || ''; pubDate = item.querySelector('pubDate')?.textContent || ''; } if (link && title) { title = title.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"'); posts.push({ title, link, pubDate }); } }); return posts; } catch (error) { console.error('RSS 파싱 오류:', error); return []; } } // 프록시를 통한 RSS 가져오기 async function fetchRssWithProxy(rssUrl, proxyUrl) { try { let fetchUrl; if (proxyUrl.includes('allorigins.win')) { fetchUrl = proxyUrl + encodeURIComponent(rssUrl); } else { fetchUrl = proxyUrl + rssUrl; } const response = await fetch(fetchUrl); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } let data; if (proxyUrl.includes('allorigins.win')) { const json = await response.json(); data = json.contents; } else { data = await response.text(); } if (data.includes(' 0) { posts = parsedPosts; success = true; showStatusMessage(`✅ ${posts.length}개의 포스트를 성공적으로 가져왔습니다!`, 'success'); break; } } catch (error) { console.error('RSS 가져오기 실패:', error); continue; } } if (success) break; } if (success) { displayRssPosts(posts); document.getElementById('rss-posts-container').classList.remove('hidden'); } else { throw new Error('RSS 피드를 가져올 수 없습니다.'); } } catch (error) { console.error('RSS 가져오기 실패:', error); showStatusMessage('RSS 피드를 가져올 수 없습니다. 올바른 블로그 주소를 입력해주세요.', 'error'); } finally { fetchButton.disabled = false; fetchButton.innerHTML = ' RSS 가져오기'; lucide.createIcons(); } } // RSS 포스트 표시 function displayRssPosts(posts) { const container = document.getElementById('rss-posts-list'); container.innerHTML = ''; selectedRssPosts = []; posts.forEach((post, index) => { const dateStr = post.pubDate ? new Date(post.pubDate).toLocaleDateString('ko-KR') : ''; const postDiv = document.createElement('div'); postDiv.className = 'rss-post-item'; postDiv.innerHTML = `
    `; const checkbox = postDiv.querySelector('input[type="checkbox"]'); checkbox.addEventListener('change', (e) => { if (e.target.checked) { if (selectedRssPosts.length >= 4) { e.target.checked = false; showStatusMessage('최대 4개까지만 선택할 수 있습니다.', 'warn'); return; } selectedRssPosts.push({ title: post.title, link: post.link }); postDiv.classList.add('selected'); } else { selectedRssPosts = selectedRssPosts.filter(p => p.link !== post.link); postDiv.classList.remove('selected'); } updateSelectedPostsCount(); if (selectedRssPosts.length === 4) { generateMainTitle(); generateButtonTexts(); document.getElementById('internal-link-config').classList.remove('hidden'); document.getElementById('internal-char-count-info').classList.remove('hidden'); setupTitleAndButtonEditing(); } else { document.getElementById('internal-link-config').classList.add('hidden'); document.getElementById('internal-link-preview').classList.add('hidden'); document.getElementById('internal-char-count-info').classList.add('hidden'); } }); container.appendChild(postDiv); }); } // 선택된 포스트 수 업데이트 function updateSelectedPostsCount() { const countElement = document.getElementById('selected-posts-count'); if (countElement) { countElement.textContent = `${selectedRssPosts.length}/4 선택됨`; countElement.className = selectedRssPosts.length === 4 ? 'text-sm text-green-600 font-bold' : 'text-sm text-gray-600'; } } // 버튼 텍스트 생성 async function generateButtonTexts() { const previewContainer = document.getElementById('internal-link-preview'); const previewButtons = document.getElementById('preview-buttons'); previewContainer.classList.remove('hidden'); previewButtons.innerHTML = '
    🤖 AI가 클릭하고 싶은 버튼 텍스트를 생성 중...
    '; try { const buttonPrompt = ` 다음 4개의 블로그 글 제목을 분석하여, 각각에 대한 매력적이고 클릭하고 싶은 버튼 텍스트를 생성해주세요. [원본 글 제목] 1. ${selectedRssPosts[0].title} 2. ${selectedRssPosts[1].title} 3. ${selectedRssPosts[2].title} 4. ${selectedRssPosts[3].title} [버튼 텍스트 생성 규칙] 1. 각 버튼은 20자 이내로 구체적이고 명확하게 2. "자세히 보기", "더보기" 같은 일반적인 표현 금지 3. 글의 핵심 가치나 혜택을 직접적으로 표현 4. 호기심을 자극하는 구체적인 내용 포함 5. 이모지 1개씩 포함 (글 내용과 관련된 것) 6. 원본 제목의 핵심 키워드는 유지하되 더 매력적으로 7. 클릭하면 어떤 정보를 얻을 수 있는지 명확히 표현 다음 형식으로 4개의 버튼 텍스트만 제공 (설명 없이): 버튼1: [텍스트] 버튼2: [텍스트] 버튼3: [텍스트] 버튼4: [텍스트] `; const response = await geminiFetch('gemini-2.5-flash', buttonPrompt); const lines = response.trim().split('\n'); generatedButtonTexts = lines.map((line, idx) => { const match = line.match(/버튼\d+:\s*(.+)/); if (match) { return match[1].trim(); } // 매칭 실패 시 원본 제목 활용 const title = selectedRssPosts[idx].title; const emojis = ['🔥', '💡', '⭐', '✨']; // 제목이 너무 길면 핵심 부분만 추출 const shortTitle = title.length > 15 ? title.substring(0, 15) + '...' : title; return `${emojis[idx]} ${shortTitle}`; }); // 미리보기 업데이트 previewButtons.innerHTML = generatedButtonTexts.map((text, idx) => ` ${text} `).join(''); showStatusMessage('✨ 클릭하고 싶은 버튼 텍스트가 생성되었습니다!', 'success'); } catch (error) { console.error('버튼 텍스트 생성 실패:', error); // 실패 시 원본 제목 기반 버튼 생성 const emojis = ['🔥', '💡', '⭐', '✨']; generatedButtonTexts = selectedRssPosts.map((post, idx) => { const title = post.title; const shortTitle = title.length > 15 ? title.substring(0, 15) : title; return `${emojis[idx]} ${shortTitle} 확인하기`; }); previewButtons.innerHTML = generatedButtonTexts.map((text, idx) => ` ${text} `).join(''); } } // 메인 제목 자동 생성 async function generateMainTitle() { const generatedTitleInput = document.getElementById('generated-main-title'); generatedTitleInput.value = '🤖 AI가 4개 글을 깊이 분석하여 매력적인 제목을 생성 중...'; try { const titlePrompt = ` 다음 4개의 블로그 글 제목을 깊이 분석하여, 이들을 포괄하는 매력적이고 구체적인 대표 제목을 생성해주세요. [분석할 4개 글 제목] 1. ${selectedRssPosts[0].title} 2. ${selectedRssPosts[1].title} 3. ${selectedRssPosts[2].title} 4. ${selectedRssPosts[3].title} [제목 생성 기준] 1. 4개 글의 공통 주제와 핵심 가치를 명확히 파악 2. 독자가 얻을 수 있는 구체적인 혜택이나 정보를 포함 3. SEO에 최적화된 핵심 키워드 포함 4. 클릭률을 높이는 숫자나 구체적인 내용 포함 5. 30자 이내로 명확하고 구체적으로 6. 막연한 표현 금지 (예: 완벽 가이드, 모든 것 등) 7. 독자가 클릭했을 때 무엇을 얻을지 명확히 표현 [금지 단어 - 절대 사용 금지] - "1등", "1위", "최고", "최상", "베스트", "추천", "TOP", "BEST" - "모든 것", "완벽", "완전", "전부" - 과장된 표현이나 순위를 나타내는 단어 금지 - 객관적이고 사실적인 표현만 사용 완전히 새로운 창의적인 제목 1개만 제공 (따옴표, 설명 없이): `; const response = await geminiFetch('gemini-2.5-flash', titlePrompt); let generatedTitle = response.trim().replace(/^["']|["']$/g, ''); // 약관 위반 단어 필터링 const prohibitedWords = ['1등', '1위', '최고', '최상', '베스트', '추천', 'BEST', 'TOP', 'No.1', '모든 것', '완벽', '완전', '전부']; prohibitedWords.forEach(word => { const regex = new RegExp(word, 'gi'); generatedTitle = generatedTitle.replace(regex, ''); }); // 제목 정리 generatedTitle = generatedTitle.replace(/\s+/g, ' ').trim(); // 제목이 너무 짧거나 비어있으면 기본 제목 생성 if (generatedTitle.length < 5) { const keywords = selectedRssPosts.map(p => { const words = p.title.split(' '); return words[0] || ''; }).filter(Boolean); generatedTitle = `${keywords[0]} 관련 핵심 정보 ${keywords.length}가지`; } generatedMainTitle = generatedTitle; generatedTitleInput.value = generatedMainTitle; updateMainTitleCharCount(); showStatusMessage('✨ AI가 매력적인 제목을 생성했습니다!', 'success'); } catch (error) { console.error('제목 생성 실패:', error); // 실패 시 기본 제목 생성 const firstTitle = selectedRssPosts[0].title; const keywords = firstTitle.split(' ').slice(0, 3).join(' '); generatedMainTitle = `${keywords} 핵심 정보 4가지`; generatedTitleInput.value = generatedMainTitle; updateMainTitleCharCount(); } } // RSS 내부링크 포스팅 생성 async function handleGenerateInternalLink() { if (selectedRssPosts.length !== 4) { showStatusMessage('정확히 4개의 글을 선택해주세요.', 'error'); return; } const publishTime = document.getElementById('internal-publish-time-input').value; const useGoogleSearch = document.getElementById('use-google-search-internal').checked; const shouldGenerateImage = document.getElementById('generate-image-checkbox-internal').checked; if (!generatedMainTitle) { showStatusMessage('메인 제목이 생성되지 않았습니다.', 'error'); return; } isProcessRunning = true; initLog(); document.getElementById('progress-container').classList.remove('hidden'); globalStartTime = Date.now(); try { addLog('🔗 RSS 내부링크 포스팅 생성 시작', 'info'); addLog(`📌 대표 제목: ${generatedMainTitle}`, 'info'); addLog(`📝 선택된 글: ${selectedRssPosts.length}개`, 'info'); addLog(`🖼️ 이미지 생성: ${shouldGenerateImage ? '예' : '아니오'}`, 'info'); // Google Search 실행 (옵션) let searchResults = null; if (useGoogleSearch && GOOGLE_SEARCH_API_KEY && GOOGLE_SEARCH_CX) { updateProgress(10, '🔍 Google에서 관련 자료 검색 중...'); searchResults = await searchGoogle(generatedMainTitle); if (searchResults) { addLog('✅ 검색 완료! 최신 정보를 반영하여 글을 작성합니다', 'success'); } } updateProgress(30, 'AI가 고품질 내부링크 포스팅을 작성 중...'); // 내부링크 버튼 HTML 생성 const internalLinkButtons = selectedRssPosts.map((post, idx) => { const buttonText = generatedButtonTexts[idx] || `🔥 ${post.title.substring(0, 20)}`; return `
    ${buttonText}
    `; }).join('\n'); // 내부링크 프롬프트 let internalLinkPrompt = ` **주제:** ${generatedMainTitle} **내부링크로 연결할 4개의 글:** ${selectedRssPosts.map((post, idx) => `${idx + 1}. ${post.title}`).join('\n')} **버튼 텍스트:** ${generatedButtonTexts.map((text, idx) => `${idx + 1}. ${text}`).join('\n')} ${searchResults ? ` **참고할 최신 정보 (Google 검색 결과):** ${searchResults} ` : ''} **특별 지침:** 1. 주제 "${generatedMainTitle}"를 중심으로 10,000자 이상의 고품질 글 작성 2. 4개의 내부링크 버튼을 본문의 자연스러운 위치에 배치 3. 버튼 배치 위치: - 첫 번째 버튼: 도입부 이후 (전체 글의 20% 지점) - 두 번째 버튼: 중간 섹션 (전체 글의 40% 지점) - 세 번째 버튼: 후반부 섹션 (전체 글의 60% 지점) - 네 번째 버튼: 결론 직전 (전체 글의 80% 지점) 4. 각 버튼 앞뒤로 해당 글과 연관된 설명 문단 작성 (버튼을 자연스럽게 소개) 5. 버튼 HTML은 다음과 같이 정확히 삽입: ${internalLinkButtons} **일반 지침:** ${WRITING_INSTRUCTIONS || defaultInstructions} `; const articleContent = await geminiFetch('gemini-2.5-flash', internalLinkPrompt); // 글자 수 계산 const textContent = articleContent.replace(/<[^>]*>/g, ''); const charCount = textContent.length; addLog(`✅ 글 작성 완료! (총 ${charCount.toLocaleString()}자)`, 'success'); // 이미지 생성 (선택사항) let finalContent = articleContent; if (shouldGenerateImage) { updateProgress(50, '🎨 AI 이미지 생성 중...'); // 이미지 프롬프트 생성 const imagePrompts = [generatedMainTitle]; const h2Regex = /]*>(.*?)<\/h2>/gi; const h2Matches = [...finalContent.matchAll(h2Regex)]; if (h2Matches.length >= 1) { imagePrompts.push(h2Matches[Math.floor(h2Matches.length / 2)][1].trim()); } const imageUrls = []; for (let i = 0; i < imagePrompts.length; i++) { const p = imagePrompts[i]; addLog(`🎨 이미지 ${i+1}/${imagePrompts.length} 생성 중: "${p.substring(0, 30)}..."`, 'info'); const imageUrl = await generatePollinationsImage(p); imageUrls.push({ url: imageUrl, alt: p }); addLog(`✅ 이미지 ${i+1} 생성 완료!`, 'success'); } // 이미지 삽입 addLog('🖼️ 본문에 이미지 삽입 중...', 'info'); let imageTags = imageUrls.map(img => { const altText = img.alt.replace(/"/g, '"'); return `
    ${altText}
    ${altText}
    `; }); if (imageTags.length > 0) { const separator = ''; const paragraphs = finalContent.split(/(<\/p>)/); const assembledParts = []; for (let i = 0; i < paragraphs.length; i += 2) { assembledParts.push(paragraphs[i] + (paragraphs[i + 1] || '')); } if (assembledParts.length > 0) { const thumbnailTag = imageTags.shift(); assembledParts[0] = assembledParts[0] + `\n${thumbnailTag}\n${separator}`; } if (imageTags.length > 0 && assembledParts.length > 2) { const secondImageTag = imageTags.shift(); const midPoint = Math.floor(assembledParts.length / 2); assembledParts.splice(midPoint, 0, secondImageTag); } finalContent = assembledParts.join(''); } addLog(`✅ ${imageUrls.length}개 이미지 삽입 완료`, 'success'); } updateProgress(70, 'SEO 태그 생성 중...'); const tagsPrompt = `다음 주제와 관련된 SEO 최적화 태그를 5-7개 생성해주세요: 주제: ${generatedMainTitle} 관련 글: ${selectedRssPosts.map(p => p.title).join(', ')} 금지 단어: 추천, 1등, 1위, 최고, 베스트, TOP, BEST 결과는 콤마로 구분된 태그만 제공:`; const tagsText = await geminiFetch('gemini-2.5-flash', tagsPrompt); const labels = processLabels(tagsText.split(',').map(tag => tag.trim()).filter(Boolean)); updateProgress(90, '포스팅 발행 준비 중...'); const postData = { title: generatedMainTitle, content: finalContent, labels: labels }; await publishPost(postData, publishTime); updateProgress(100, '✅ RSS 내부링크 포스팅 완료!'); // 총 소요 시간 계산 const totalElapsed = Math.floor((Date.now() - globalStartTime) / 1000); addLog(`🎉 RSS 내부링크 포스팅 완료! (총 ${totalElapsed}초 소요)`, 'success'); // 초기화 selectedRssPosts = []; selectedMultiBlogPosts = []; totalSelectedPosts = 0; generatedMainTitle = ''; generatedButtonTexts = []; document.getElementById('generated-main-title').value = ''; document.getElementById('internal-publish-time-input').value = ''; document.getElementById('multi-blog-rss-container').classList.add('hidden'); document.getElementById('internal-link-config').classList.add('hidden'); document.getElementById('internal-char-count-info').classList.add('hidden'); document.getElementById('selected-posts-summary').classList.add('hidden'); // 멀티 블로그 입력 초기화 const blogInputs = document.querySelectorAll('.multi-blog-input'); blogInputs.forEach(input => { input.value = ''; }); } catch (error) { console.error('RSS 내부링크 포스팅 생성 실패:', error); addLog(`❌ 오류: ${error.message}`, 'error'); showStatusMessage('RSS 내부링크 포스팅 생성에 실패했습니다.', 'error'); } finally { isProcessRunning = false; } } // 도메인 추출 함수 function extractDomain(url) { try { const urlObj = new URL(url); return urlObj.hostname.replace('www.', ''); } catch { return url; } } // 검색 쿼리 최적화 함수 function optimizeSearchQuery(topic) { const currentYear = new Date().getFullYear(); const currentMonth = new Date().getMonth() + 1; let enhancedQuery = topic; const monthKeywords = { '1월': `${currentYear}년 1월`, '2월': `${currentYear}년 2월`, '3월': `${currentYear}년 3월`, '4월': `${currentYear}년 4월`, '5월': `${currentYear}년 5월`, '6월': `${currentYear}년 6월`, '7월': `${currentYear}년 7월`, '8월': `${currentYear}년 8월`, '9월': `${currentYear}년 9월`, '10월': `${currentYear}년 10월`, '11월': `${currentYear}년 11월`, '12월': `${currentYear}년 12월` }; const seasonKeywords = { '봄': `${currentYear}년 봄 3월 4월 5월`, '여름': `${currentYear}년 여름 6월 7월 8월`, '가을': `${currentYear}년 가을 9월 10월 11월`, '겨울': `${currentYear}년 겨울 12월 1월 2월` }; if (topic.includes('축제') || topic.includes('행사') || topic.includes('이벤트')) { for (const [month, dateStr] of Object.entries(monthKeywords)) { if (topic.includes(month)) { enhancedQuery = topic.replace(month, dateStr); break; } } for (const [season, dateStr] of Object.entries(seasonKeywords)) { if (topic.includes(season)) { enhancedQuery = topic + ` ${dateStr}`; break; } } if (enhancedQuery === topic && !topic.includes(String(currentYear))) { enhancedQuery = `${topic} ${currentYear}년 일정 날짜`; } } const needsLatestInfo = ['가격', '요금', '시간', '영업시간', '운영시간', '예약', '할인', '이벤트']; if (needsLatestInfo.some(keyword => topic.includes(keyword))) { enhancedQuery = `${enhancedQuery} ${currentYear}년 최신`; } const locationKeywords = ['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종', '경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주']; const hasLocation = locationKeywords.some(loc => topic.includes(loc)); if (hasLocation && (topic.includes('맛집') || topic.includes('카페') || topic.includes('여행'))) { enhancedQuery = `${enhancedQuery} 인기`; } addLog(`🔎 검색 쿼리 최적화: "${topic}" → "${enhancedQuery}"`, 'info'); return enhancedQuery; } // Google Search API 관련 함수들 function loadSearchUsage() { const saved = localStorage.getItem('googleSearchUsage'); if (saved) { searchUsageToday = JSON.parse(saved); const today = new Date().toDateString(); if (searchUsageToday.date !== today) { searchUsageToday = { date: today, count: 0 }; saveSearchUsage(); } } updateSearchQuotaDisplay(); } function saveSearchUsage() { localStorage.setItem('googleSearchUsage', JSON.stringify(searchUsageToday)); } function updateSearchQuotaDisplay() { if (GOOGLE_SEARCH_API_KEY && GOOGLE_SEARCH_CX) { const searchApiStatus = document.getElementById('search-api-status'); if (searchApiStatus) { searchApiStatus.classList.remove('hidden'); } const remaining = SEARCH_DAILY_LIMIT - searchUsageToday.count; const usagePercent = (searchUsageToday.count / SEARCH_DAILY_LIMIT) * 100; const searchQuotaDisplay = document.getElementById('search-quota-display'); const searchUsageBar = document.getElementById('search-usage-bar'); if (searchQuotaDisplay) { searchQuotaDisplay.textContent = `${remaining}/${SEARCH_DAILY_LIMIT}회`; } if (searchUsageBar) { searchUsageBar.style.width = `${usagePercent}%`; if (usagePercent >= 90) { searchUsageBar.style.backgroundColor = '#ef4444'; } else if (usagePercent >= 70) { searchUsageBar.style.backgroundColor = '#f59e0b'; } else { searchUsageBar.style.backgroundColor = '#22c55e'; } } const bulkRemainingSearches = document.getElementById('bulk-remaining-searches'); if (bulkRemainingSearches) { bulkRemainingSearches.textContent = remaining; } const useGoogleSearchBulk = document.getElementById('use-google-search-bulk'); if (useGoogleSearchBulk && useGoogleSearchBulk.checked) { const topicIdeasList = document.getElementById('topic-ideas-list'); const selectedCount = topicIdeasList ? topicIdeasList.querySelectorAll('input[type="checkbox"]:checked').length : 0; if (selectedCount > remaining) { const bulkSearchQuotaWarning = document.getElementById('bulk-search-quota-warning'); const bulkSearchWarningText = document.getElementById('bulk-search-warning-text'); if (bulkSearchQuotaWarning) { bulkSearchQuotaWarning.classList.remove('hidden'); } if (bulkSearchWarningText) { bulkSearchWarningText.textContent = `⚠️ 선택한 주제(${selectedCount}개)가 남은 검색 횟수(${remaining}회)를 초과합니다. 처음 ${remaining}개만 검색이 적용되고, 나머지는 검색 없이 진행됩니다.`; } } else { const bulkSearchQuotaWarning = document.getElementById('bulk-search-quota-warning'); if (bulkSearchQuotaWarning) { bulkSearchQuotaWarning.classList.add('hidden'); } } } } else { const searchApiStatus = document.getElementById('search-api-status'); if (searchApiStatus) { searchApiStatus.classList.add('hidden'); } } } function updateSearchStatus(status, text) { const searchStatusIndicator = document.getElementById('search-status-indicator'); const searchStatusText = document.getElementById('search-status-text'); if (!searchStatusIndicator) return; const statusDot = searchStatusIndicator.querySelector('.search-status-dot'); if (searchStatusText) { searchStatusText.textContent = text; } switch (status) { case 'active': searchStatusIndicator.classList.add('search-status-active'); searchStatusIndicator.classList.remove('search-status-inactive'); if (statusDot) statusDot.style.color = '#22c55e'; break; case 'inactive': searchStatusIndicator.classList.remove('search-status-active'); searchStatusIndicator.classList.add('search-status-inactive'); if (statusDot) statusDot.style.color = '#ef4444'; break; case 'searching': if (statusDot) statusDot.style.color = '#3b82f6'; break; } } // Google Search 함수 async function searchGoogle(query, jobIndex = null, totalJobs = null) { if (!GOOGLE_SEARCH_API_KEY || !GOOGLE_SEARCH_CX) { addLog('⚠️ Google Search API가 설정되지 않아 검색을 건너뜁니다', 'warn'); return null; } if (searchUsageToday.count >= SEARCH_DAILY_LIMIT) { addLog('⚠️ 일일 검색 한도(100회)를 초과했습니다. 검색 없이 진행합니다.', 'warn'); return null; } try { const optimizedQuery = optimizeSearchQuery(query); const searchProgressIndicator = document.getElementById('search-progress-indicator'); const searchProgressText = document.getElementById('search-progress-text'); if (searchProgressIndicator) { searchProgressIndicator.classList.remove('hidden'); if (searchProgressText) { if (jobIndex && totalJobs) { searchProgressText.textContent = `Google 검색 중... (${jobIndex}/${totalJobs}) "${optimizedQuery.substring(0, 30)}..."`; } else { searchProgressText.textContent = `Google에서 "${optimizedQuery.substring(0, 30)}..." 검색 중...`; } } } updateSearchStatus('searching', '검색 중'); const url = `https://www.googleapis.com/customsearch/v1?key=${GOOGLE_SEARCH_API_KEY}&cx=${GOOGLE_SEARCH_CX}&q=${encodeURIComponent(optimizedQuery)}&num=${SEARCH_RESULTS_COUNT}&hl=ko&lr=lang_ko`; addLog(`🔍 Google 검색 시작: "${optimizedQuery}"`, 'info'); const response = await fetch(url); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error?.message || `검색 실패: ${response.status}`); } const data = await response.json(); searchUsageToday.count++; saveSearchUsage(); updateSearchQuotaDisplay(); if (!data.items || data.items.length === 0) { addLog('검색 결과가 없습니다', 'info'); return null; } searchedSources = data.items.map(item => ({ title: item.title, url: item.link, domain: extractDomain(item.link) })); const formattedResults = data.items.map((item, index) => { const domain = extractDomain(item.link); return `[검색 결과 ${index + 1}] 제목: ${item.title} 출처: ${domain} URL: ${item.link} 내용: ${item.snippet} ---`; }).join('\n'); addLog(`✅ ${data.items.length}개의 검색 결과를 찾았습니다 (사용: ${searchUsageToday.count}/${SEARCH_DAILY_LIMIT})`, 'success'); const domainList = searchedSources.map((source, idx) => `${idx+1}. ${source.domain}`).join(', '); addLog(`📌 검색된 사이트: ${domainList}`, 'info'); const singleSearchResultPreview = document.getElementById('single-search-result-preview'); const searchSourcesContainer = document.getElementById('search-sources-container'); if (singleSearchResultPreview && !singleSearchResultPreview.classList.contains('hidden')) { singleSearchResultPreview.innerHTML = ` 🔍 검색 결과 미리보기 (상위 5개):
    ${data.items.slice(0, 5).map((item, index) => { const domain = extractDomain(item.link); return `
    ${domain}
    ${index + 1}. ${item.title}
    ${item.link}
    ${item.snippet}
    `}).join('')} `; if (searchSourcesContainer) { searchSourcesContainer.innerHTML = ` 📚 참고한 사이트 목록 (${searchedSources.length}개): ${searchedSources.map((source, idx) => `
    ${idx + 1}
    ${source.domain}
    ${source.title}
    `).join('')} `; searchSourcesContainer.classList.remove('hidden'); } } updateSearchStatus('active', '완료'); return formattedResults; } catch (error) { console.error('Google 검색 실패:', error); addLog(`❌ 검색 실패: ${error.message}`, 'error'); updateSearchStatus('inactive', '오류'); return null; } finally { const searchProgressIndicator = document.getElementById('search-progress-indicator'); if (searchProgressIndicator) { setTimeout(() => { searchProgressIndicator.classList.add('hidden'); }, 1000); } } } // 나머지 모든 함수들 계속... function updateSelectedTopicCount() { const topicIdeasList = document.getElementById('topic-ideas-list'); const selectedTopicCountEl = document.getElementById('selected-topic-count'); if (!topicIdeasList || !selectedTopicCountEl) return; const selectedCheckboxes = topicIdeasList.querySelectorAll('input[type="checkbox"]:checked'); const selectedCount = selectedCheckboxes.length; selectedTopicCountEl.textContent = selectedCount; } window.selectTopicForSinglePosting = function(topic) { const textarea = document.createElement('textarea'); textarea.innerHTML = topic; const decodedTopic = textarea.value; const postTopicInput = document.getElementById('post-topic-input'); if (postTopicInput) { postTopicInput.value = decodedTopic; } handleTabClick({ target: document.querySelector('[data-tab="ai-single-mode"]') }); const postingView = document.getElementById('posting-view'); if (postingView) { postingView.scrollTop = 0; } } // 블로그 선택 모달 관련 함수들 async function showBlogSelectionModal() { if (isProcessRunning) { showStatusMessage('작업이 진행 중일 때는 블로그를 변경할 수 없습니다.', 'error'); return; } const blogSelectionModal = document.getElementById('blog-selection-modal'); const blogModalLoader = document.getElementById('blog-modal-loader'); const blogModalList = document.getElementById('blog-modal-list'); if (blogSelectionModal) { blogSelectionModal.classList.add('active'); } if (blogModalLoader) { blogModalLoader.classList.remove('hidden'); } if (blogModalList) { blogModalList.innerHTML = ''; } try { if (userBlogs.length === 0) { const data = await fetchWithAuth('https://www.googleapis.com/blogger/v3/users/self/blogs'); userBlogs = data.items || []; } renderBlogModalList(userBlogs); } catch (error) { if (blogModalList) { blogModalList.innerHTML = `
    블로그 목록을 불러올 수 없습니다: ${error.message}
    `; } } finally { if (blogModalLoader) { blogModalLoader.classList.add('hidden'); } } } function renderBlogModalList(blogs) { const blogModalList = document.getElementById('blog-modal-list'); if (!blogModalList) return; if (blogs.length === 0) { blogModalList.innerHTML = '
    소유한 블로그가 없습니다.
    '; return; } blogModalList.innerHTML = blogs.map(blog => { const isCurrentBlog = blog.id === selectedBlogId; return `

    ${blog.name}

    ${blog.url}

    `; }).join(''); } window.selectBlogFromModal = function(blogId, blogName, blogUrl) { if (blogId === selectedBlogId) { closeBlogSelectionModal(); return; } selectedBlogId = blogId; selectedBlogName = blogName; selectedBlogUrl = blogUrl; const blogTitleEl = document.getElementById('blog-title'); if (blogTitleEl) { blogTitleEl.textContent = blogName; } const blogUrlText = document.getElementById('blog-url-text'); if (blogUrlText) { blogUrlText.textContent = blogUrl; } WRITING_INSTRUCTIONS = localStorage.getItem(`bloggerInstructions_${selectedBlogId}`) || defaultInstructions; const writingInstructionsInput = document.getElementById('writing-instructions-input'); if (writingInstructionsInput) { writingInstructionsInput.value = WRITING_INSTRUCTIONS; } scheduledPostTitles.clear(); scheduledPostTimes.clear(); closeBlogSelectionModal(); showStatusMessage(`"${blogName}" 블로그로 변경되었습니다.`, 'success'); const activeTab = document.querySelector('.tab-button.active'); if (activeTab && activeTab.dataset.tab === 'scheduled-list') { fetchScheduledPosts(); } } window.closeBlogSelectionModal = function() { const blogSelectionModal = document.getElementById('blog-selection-modal'); if (blogSelectionModal) { blogSelectionModal.classList.remove('active'); } } document.addEventListener('click', (e) => { if (e.target.classList.contains('modal-backdrop')) { if (e.target.closest('#blog-selection-modal')) { closeBlogSelectionModal(); } else if (e.target.closest('#token-extend-modal')) { closeTokenModal(); } } }); // 예약 일정 미리보기 함수들 async function handlePreviewSchedule() { const topicIdeasList = document.getElementById('topic-ideas-list'); const scheduleTimesInput = document.getElementById('schedule-times-input'); const scheduleDaysInput = document.getElementById('schedule-days-input'); const bulkStartDateInput = document.getElementById('bulk-start-date-input'); if (!topicIdeasList) return; const selectedCheckboxes = Array.from(topicIdeasList.querySelectorAll('input[type="checkbox"]:checked')); const selectedTopics = selectedCheckboxes.map(cb => cb.value); const timesText = scheduleTimesInput?.value.trim() || ''; const daysValue = scheduleDaysInput?.value.trim() || ''; const startDate = bulkStartDateInput?.value; if (selectedTopics.length === 0) { showStatusMessage('예약할 주제를 선택해주세요.', 'error'); return; } const times = timesText.split(',').map(t => t.trim()).filter(Boolean); if (times.length === 0 || !times.every(t => /^\d{1,2}:\d{2}$/.test(t))) { showStatusMessage('올바른 시간 형식을 입력해주세요. (예: 09:00, 17:00)', 'error'); return; } const days = parseInt(daysValue, 10); if (isNaN(days) || days < 1) { showStatusMessage('예약 기간은 1일 이상이어야 합니다.', 'error'); return; } if (!startDate) { showStatusMessage('시작 날짜를 선택해주세요.', 'error'); return; } await generateSchedulePreviewSmart(selectedTopics, times, days, startDate); const schedulePreviewContainer = document.getElementById('schedule-preview-container'); if (schedulePreviewContainer) { schedulePreviewContainer.classList.remove('hidden'); } } async function generateSchedulePreviewSmart(topics, times, days, startDateStr) { const schedulePreview = document.getElementById('schedule-preview'); if (!schedulePreview) return; const startDate = new Date(startDateStr); let html = ''; await fetchScheduledPostsWithTimes(); let topicIndex = 0; let totalScheduled = 0; let conflictCount = 0; let pastCount = 0; const postsPerDay = times.length; const totalSlots = postsPerDay * days; const exactDaysNeeded = Math.ceil(topics.length / postsPerDay); if (totalSlots !== topics.length) { if (totalSlots < topics.length) { html += `
    ⚠️ ${topics.length}개 주제 중 ${totalSlots}개만 예약됩니다.
    모든 주제를 예약하려면 ${exactDaysNeeded}일이 필요합니다.
    `; } else { html += `
    ❌ ${topics.length}개 주제는 ${exactDaysNeeded}일이면 충분합니다.
    현재 ${days}일로 설정되어 ${days - exactDaysNeeded}일이 초과됩니다.
    `; } } for (let dayOffset = 0; dayOffset < days && topicIndex < topics.length; dayOffset++) { const currentDate = new Date(startDate); currentDate.setDate(currentDate.getDate() + dayOffset); const dateKey = currentDate.toDateString(); const dateStr = currentDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short' }); for (const timeStr of times) { if (topicIndex >= topics.length) break; const [hour, minute] = timeStr.split(':').map(Number); const publishDate = new Date(currentDate); publishDate.setHours(hour, minute, 0, 0); const isConflict = scheduledPostTimes.has(dateKey) && scheduledPostTimes.get(dateKey).has(timeStr); const now = new Date(); const isPastTime = publishDate <= now; if (isConflict) { html += `
    ${dateStr} ${timeStr} - [예약됨] 건너뛰기
    `; conflictCount++; continue; } if (isPastTime) { html += `
    ${dateStr} ${timeStr} - [과거] 건너뛰기
    `; pastCount++; continue; } totalScheduled++; html += `
    ${dateStr} ${timeStr} - ${topics[topicIndex].substring(0, 30)}${topics[topicIndex].length > 30 ? '...' : ''}
    `; topicIndex++; } } const smartSummaryHtml = `
    🧠 스마트 스케줄링 결과:
    • 시작 날짜: ${startDate.toLocaleDateString('ko-KR')}
    • 선택한 주제: ${topics.length}개
    • 실제 예약: ${totalScheduled}개
    ${conflictCount > 0 ? `
    • 기존 예약과 충돌: ${conflictCount}개 (자동 건너뛰기)
    ` : ''} ${pastCount > 0 ? `
    • 과거 시간: ${pastCount}개 (자동 건너뛰기)
    ` : ''} ${totalScheduled > 0 ? `
    • 성공적으로 예약 가능: ${totalScheduled}개
    ` : ''}
    `; if (totalScheduled === 0) { html = smartSummaryHtml + '
    예약 가능한 일정이 없습니다.
    '; } else { html = smartSummaryHtml + html; } schedulePreview.innerHTML = html; } // 실시간 시계 함수 function startRealtimeClock() { if (realtimeClockInterval) { clearInterval(realtimeClockInterval); } const updateClock = () => { const now = new Date(); const timeString = now.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const realtimeClockEl = document.getElementById('realtime-clock'); if (realtimeClockEl) { realtimeClockEl.textContent = `🕐 ${timeString}`; } }; updateClock(); realtimeClockInterval = setInterval(updateClock, 1000); } // 다음 포스팅 시간 표시 function updateNextPostingTime() { const nextPostingTimeEl = document.getElementById('next-posting-time'); if (bulkScheduleQueue.length === 0 || !bulkScheduleQueue[0].publishDate) { if (nextPostingTimeEl) { nextPostingTimeEl.textContent = ''; } return; } const nextJob = bulkScheduleQueue[0]; const nextTime = new Date(nextJob.publishDate); const now = new Date(); const diff = nextTime - now; if (diff > 0) { const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diff % (1000 * 60)) / 1000); const timeString = nextTime.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); let remainingText = ''; if (days > 0) { remainingText = `${days}일 ${hours}시간 ${minutes}분 ${seconds}초 후`; } else if (hours > 0) { remainingText = `${hours}시간 ${minutes}분 ${seconds}초 후`; } else if (minutes > 0) { remainingText = `${minutes}분 ${seconds}초 후`; } else { remainingText = `${seconds}초 후`; } if (nextPostingTimeEl) { nextPostingTimeEl.innerHTML = `⏰ 다음 예약: ${timeString} (${remainingText})`; } } } // 큐 관리 함수들 function updateQueueStatus() { const queueStatus = document.getElementById('queue-status'); const queueInfo = document.getElementById('queue-info'); const totalInQueue = bulkScheduleQueue.length; if (totalInQueue > 0 || isProcessPaused) { if (queueStatus) { queueStatus.classList.remove('hidden'); } if (queueInfo) { queueInfo.textContent = `대기 중인 작업: ${totalInQueue}개 ${isProcessPaused ? '(일시정지)' : ''}`; } if (nextPostingTimeInterval) { clearInterval(nextPostingTimeInterval); } updateNextPostingTime(); nextPostingTimeInterval = setInterval(updateNextPostingTime, 1000); } else { if (queueStatus) { queueStatus.classList.add('hidden'); } if (nextPostingTimeInterval) { clearInterval(nextPostingTimeInterval); } } } function handlePauseResume() { const pauseResumeBtn = document.getElementById('pause-resume-btn'); const currentStatusEl = document.getElementById('current-status'); const progressBar = document.getElementById('progress-bar'); if (isProcessPaused) { isProcessPaused = false; if (pauseResumeBtn) { pauseResumeBtn.innerHTML = ' 일시정지'; } addLog('⏯️ 작업을 재개합니다', 'info'); if (progressBar) { progressBar.classList.remove('paused'); } if (pausedQueue.length > 0) { bulkScheduleQueue = [...pausedQueue, ...bulkScheduleQueue]; pausedQueue = []; processBulkScheduleQueue(); } } else { isProcessPaused = true; if (pauseResumeBtn) { pauseResumeBtn.innerHTML = ' 재개'; } addLog('⏸️ 작업을 일시정지합니다', 'warn'); if (progressBar) { progressBar.classList.add('paused'); } if (currentStatusEl) { currentStatusEl.classList.add('paused-indicator'); } } updateQueueStatus(); lucide.createIcons(); } function handleClearQueue() { if (bulkScheduleQueue.length === 0) { showStatusMessage('대기 중인 작업이 없습니다', 'info'); return; } if (confirm(`대기 중인 ${bulkScheduleQueue.length}개의 작업을 모두 취소하시겠습니까?`)) { const clearedCount = bulkScheduleQueue.length; bulkScheduleQueue = []; pausedQueue = []; updateQueueStatus(); addLog(`🗑️ ${clearedCount}개의 대기 작업이 취소되었습니다`, 'warn'); showStatusMessage(`${clearedCount}개의 작업이 취소되었습니다`, 'info'); } } // 작업 중지/재개 함수들 function handleStopProcess() { if (!confirm('진행 중인 작업을 완전히 중지하시겠습니까?\n(재개하려면 처음부터 다시 시작해야 합니다)')) { return; } isProcessRunning = false; isProcessPaused = false; bulkScheduleQueue = []; pausedQueue = []; if (waitingInterval) { clearInterval(waitingInterval); waitingInterval = null; } const message = completedBulkJobs > 0 ? `작업이 중지되었습니다. (${completedBulkJobs}/${totalBulkJobs}개 완료)` : '작업이 중지되었습니다.'; addLog(message, 'error'); updateProgress((completedBulkJobs / (totalBulkJobs || 1)) * 100, message); setPublishButtonState(false, 'bulk'); const currentStatusEl = document.getElementById('current-status'); const progressBar = document.getElementById('progress-bar'); const stopButton = document.getElementById('stop-button'); const resumeButton = document.getElementById('resume-button'); if (currentStatusEl) { currentStatusEl.textContent = '작업 중지됨'; currentStatusEl.classList.remove('paused-indicator'); } if (progressBar) { progressBar.classList.add('error'); progressBar.classList.remove('paused'); } updateQueueStatus(); if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } if (stopButton) stopButton.classList.add('hidden'); if (resumeButton) resumeButton.classList.add('hidden'); } function handleResumeProcess() { if (pausedQueue.length === 0 && bulkScheduleQueue.length === 0) { showStatusMessage('재개할 작업이 없습니다', 'info'); return; } isProcessRunning = true; isProcessPaused = false; addLog('▶️ 작업을 재개합니다', 'info'); const progressBar = document.getElementById('progress-bar'); const currentStatusEl = document.getElementById('current-status'); const stopButton = document.getElementById('stop-button'); const resumeButton = document.getElementById('resume-button'); if (progressBar) { progressBar.classList.remove('paused', 'error'); } if (currentStatusEl) { currentStatusEl.classList.remove('paused-indicator'); } if (stopButton) stopButton.classList.remove('hidden'); if (resumeButton) resumeButton.classList.add('hidden'); if (pausedQueue.length > 0) { bulkScheduleQueue = [...pausedQueue, ...bulkScheduleQueue]; pausedQueue = []; } updateQueueStatus(); processBulkScheduleQueue(); } // 토큰 관리 함수들 function updateAutoRefreshStatus() { const autoRefreshStatus = document.getElementById('auto-refresh-status'); if (autoRefreshStatus) { autoRefreshStatus.textContent = isAutoRefreshEnabled ? '켬' : '끔'; autoRefreshStatus.style.color = isAutoRefreshEnabled ? '#22c55e' : '#ef4444'; } updateTokenExplanation(); } function updateTokenExplanation() { const tokenExplanation = document.getElementById('token-explanation'); if (!tokenExplanation) return; if (isAutoRefreshEnabled) { tokenExplanation.innerHTML = '🟢 자동 연장 켜짐: 세션이 만료되기 10분 전에 자동으로 연장됩니다.'; } else { tokenExplanation.innerHTML = '🔴 자동 연장 꺼짐: 세션 만료 5분 전에 알림이 표시됩니다. "연장" 버튼으로 수동 연장 가능.'; } } function startTokenCountdown() { const tokenStatusBar = document.getElementById('token-status-bar'); if (tokenCountdownInterval) { clearInterval(tokenCountdownInterval); } if (tokenStatusBar) { tokenStatusBar.classList.remove('hidden'); } tokenCountdownInterval = setInterval(() => { updateTokenDisplay(); }, 1000); updateTokenDisplay(); setupTokenWarning(); if (isAutoRefreshEnabled) { setupAutoRefresh(); } } function updateTokenDisplay() { if (!tokenExpiryTime) return; const now = Date.now(); const remaining = tokenExpiryTime - now; const tokenTimer = document.getElementById('token-timer'); const tokenProgress = document.getElementById('token-progress'); if (remaining <= 0) { clearInterval(tokenCountdownInterval); if (tokenTimer) tokenTimer.textContent = '00:00'; if (tokenProgress) { tokenProgress.style.width = '0%'; tokenProgress.style.backgroundColor = '#ef4444'; } showTokenExpiredAlert(); return; } const totalDuration = TOKEN_DURATION; const percentRemaining = (remaining / totalDuration) * 100; const minutes = Math.floor(remaining / 60000); const seconds = Math.floor((remaining % 60000) / 1000); if (tokenTimer) { tokenTimer.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } if (tokenProgress) { tokenProgress.style.width = `${percentRemaining}%`; if (remaining <= TOKEN_WARNING_TIME) { tokenProgress.style.backgroundColor = '#ef4444'; tokenTimer?.classList.add('token-warning'); } else if (remaining <= TOKEN_AUTO_REFRESH_TIME) { tokenProgress.style.backgroundColor = '#f59e0b'; tokenTimer?.classList.remove('token-warning'); } else { tokenProgress.style.backgroundColor = '#ffffff'; tokenTimer?.classList.remove('token-warning'); } } if (isAutoRefreshEnabled && !isRefreshing) { const minutesRemaining = Math.floor(remaining / 60000); const secondsRemaining = Math.floor((remaining % 60000) / 1000); if (minutesRemaining === 10 && secondsRemaining <= 1) { console.log(`자동 토큰 갱신 실행 (남은 시간: ${minutesRemaining}분 ${secondsRemaining}초)`); extendSession(true); } } } function setupTokenWarning() { if (tokenWarningTimer) { clearTimeout(tokenWarningTimer); } const timeUntilWarning = tokenExpiryTime - Date.now() - TOKEN_WARNING_TIME; if (timeUntilWarning > 0) { tokenWarningTimer = setTimeout(() => { if (!isAutoRefreshEnabled) { showTokenWarningModal(); } }, timeUntilWarning); } } function setupAutoRefresh() { if (tokenRefreshTimer) { clearTimeout(tokenRefreshTimer); tokenRefreshTimer = null; } } function handleAutoRefreshToggle() { const autoRefreshToggle = document.getElementById('auto-refresh-toggle'); if (!autoRefreshToggle) return; isAutoRefreshEnabled = autoRefreshToggle.checked; localStorage.setItem('autoRefreshToken', isAutoRefreshEnabled); updateAutoRefreshStatus(); if (isAutoRefreshEnabled) { showStatusMessage('자동 연장이 활성화되었습니다. 세션이 만료 10분 전에 자동으로 갱신됩니다.', 'success'); } else { showStatusMessage('자동 연장이 비활성화되었습니다. 세션 만료 전 수동으로 연장해주세요.', 'info'); } } function extendSession(isAuto = false) { if (!tokenClient) { console.error('토큰 클라이언트가 초기화되지 않았습니다.'); showStatusMessage('Google 로그인 초기화 중입니다. 잠시 후 다시 시도해주세요.', 'error'); return; } if (isRefreshing) { console.log('이미 토큰 갱신 중입니다.'); return; } isRefreshing = true; try { tokenClient.requestAccessToken({ prompt: '' }); if (!isAuto) { closeTokenModal(); showStatusMessage('세션을 연장하는 중...', 'info'); } else { const notification = document.createElement('div'); notification.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; notification.innerHTML = '🔄 세션이 자동으로 연장되었습니다 (만료 10분 전)'; document.body.appendChild(notification); setTimeout(() => { notification.remove(); }, 3000); addLog('🔄 세션 자동 연장 완료 (만료 10분 전)', 'success'); } } catch (error) { console.error('세션 연장 실패:', error); isRefreshing = false; showStatusMessage('세션 연장에 실패했습니다. 다시 로그인해주세요.', 'error'); } } function showTokenWarningModal() { const tokenExtendModal = document.getElementById('token-extend-modal'); const modalTimeRemaining = document.getElementById('modal-time-remaining'); if (tokenExtendModal) { tokenExtendModal.classList.remove('hidden'); } const modalInterval = setInterval(() => { if (!tokenExpiryTime) { clearInterval(modalInterval); return; } const remaining = tokenExpiryTime - Date.now(); if (remaining <= 0) { clearInterval(modalInterval); closeTokenModal(); return; } const minutes = Math.floor(remaining / 60000); const seconds = Math.floor((remaining % 60000) / 1000); if (modalTimeRemaining) { modalTimeRemaining.textContent = `${minutes}분 ${seconds}초`; } }, 1000); } window.closeTokenModal = function() { const tokenExtendModal = document.getElementById('token-extend-modal'); if (tokenExtendModal) { tokenExtendModal.classList.add('hidden'); } } window.extendSession = extendSession; function showTokenExpiredAlert() { showStatusMessage('세션이 만료되었습니다. 다시 로그인해주세요.', 'error'); setTimeout(() => { handleLogout(); }, 2000); } function handleVisibilityChange() { if (!document.hidden && accessToken && tokenExpiryTime) { const remaining = tokenExpiryTime - Date.now(); if (remaining <= 0) { showTokenExpiredAlert(); } else if (remaining <= TOKEN_WARNING_TIME && !isAutoRefreshEnabled) { showTokenWarningModal(); } updateTokenDisplay(); } } function showView(viewName) { const settingsView = document.getElementById('settings-view'); const blogSelectionView = document.getElementById('blog-selection-view'); const postingView = document.getElementById('posting-view'); if (settingsView) settingsView.style.display = 'none'; if (blogSelectionView) blogSelectionView.style.display = 'none'; if (postingView) postingView.style.display = 'none'; if (viewName === 'settings' && settingsView) settingsView.style.display = 'flex'; if (viewName === 'blog-selection' && blogSelectionView) blogSelectionView.style.display = 'block'; if (viewName === 'posting' && postingView) postingView.style.display = 'block'; } function handleTabClick(e) { const targetTab = e.target.closest('.tab-button'); if (!targetTab) return; document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active')); targetTab.classList.add('active'); document.querySelectorAll('.tab-content').forEach(content => content.style.display = 'none'); const activeContent = document.getElementById(`${targetTab.dataset.tab}-content`); if (activeContent) activeContent.style.display = 'block'; if (targetTab.dataset.tab === 'scheduled-list') { fetchScheduledPosts(); } } // 설정 및 인증 function loadSettings() { CLIENT_ID = localStorage.getItem('bloggerClientId'); GEMINI_API_KEY = localStorage.getItem('geminiApiKey'); GOOGLE_SEARCH_API_KEY = localStorage.getItem('googleSearchApiKey'); GOOGLE_SEARCH_CX = localStorage.getItem('googleSearchCx'); const clientIdInput = document.getElementById('client-id-input'); const geminiApiKeyInput = document.getElementById('gemini-api-key-input'); const googleSearchApiKeyInput = document.getElementById('google-search-api-key-input'); const googleSearchCxInput = document.getElementById('google-search-cx-input'); if (clientIdInput) clientIdInput.value = CLIENT_ID || ''; if (geminiApiKeyInput) geminiApiKeyInput.value = GEMINI_API_KEY || ''; if (googleSearchApiKeyInput) googleSearchApiKeyInput.value = GOOGLE_SEARCH_API_KEY || ''; if (googleSearchCxInput) googleSearchCxInput.value = GOOGLE_SEARCH_CX || ''; } async function handleSaveSettings() { const clientIdInput = document.getElementById('client-id-input'); const geminiApiKeyInput = document.getElementById('gemini-api-key-input'); const googleSearchApiKeyInput = document.getElementById('google-search-api-key-input'); const googleSearchCxInput = document.getElementById('google-search-cx-input'); const settingsErrorEl = document.getElementById('settings-error'); const googleLoginContainer = document.getElementById('google-login-container'); const clientId = clientIdInput?.value.trim(); const geminiApiKey = geminiApiKeyInput?.value.trim(); const googleSearchApiKey = googleSearchApiKeyInput?.value.trim(); const googleSearchCx = googleSearchCxInput?.value.trim(); if (!clientId || !geminiApiKey) { if (settingsErrorEl) { settingsErrorEl.textContent = '필수 필드를 입력해주세요. (Google OAuth 클라이언트 ID, Gemini API 키)'; settingsErrorEl.classList.remove('hidden'); } return; } localStorage.setItem('bloggerClientId', clientId); localStorage.setItem('geminiApiKey', geminiApiKey); if (googleSearchApiKey && googleSearchCx) { localStorage.setItem('googleSearchApiKey', googleSearchApiKey); localStorage.setItem('googleSearchCx', googleSearchCx); } loadSettings(); if (settingsErrorEl) settingsErrorEl.classList.add('hidden'); if (googleLoginContainer) googleLoginContainer.classList.remove('hidden'); if (window.google && window.google.accounts) { initializeGsi(); } else { showStatusMessage('Google API를 로드하는 중입니다. 잠시만 기다려주세요...', 'info'); const checkInterval = setInterval(() => { if (window.google && window.google.accounts) { clearInterval(checkInterval); initializeGsi(); showStatusMessage('Google 로그인 준비가 완료되었습니다.', 'success'); } }, 100); } } function initializeGsi() { if (!CLIENT_ID) { console.error('CLIENT_ID가 설정되지 않았습니다.'); return; } if (!window.google || !window.google.accounts) { console.error('Google API가 아직 로드되지 않았습니다.'); showStatusMessage('Google API 로드 중... 잠시 후 다시 시도해주세요.', 'warn'); return; } try { tokenClient = google.accounts.oauth2.initTokenClient({ client_id: CLIENT_ID, scope: 'https://www.googleapis.com/auth/blogger', callback: handleTokenResponse, }); console.log('Google 로그인 초기화 성공!'); } catch (error) { console.error('Google 로그인 초기화 실패:', error); showStatusMessage('Google 로그인 초기화 실패. 클라이언트 ID를 확인해주세요.', 'error'); } } function handleTokenResponse(response) { isRefreshing = false; if (response.error) { if (response.error === 'popup_closed_by_user') { showStatusMessage('로그인이 취소되었습니다.', 'warn'); } else { showStatusMessage('인증에 실패했습니다: ' + response.error, 'error'); } if (accessToken) { return; } else { showView('settings'); return; } } const wasLoggedIn = !!accessToken; accessToken = response.access_token; const expiresIn = response.expires_in || 3600; tokenExpiryTime = Date.now() + (expiresIn * 1000); startTokenCountdown(); if (wasLoggedIn) { const minutes = Math.floor((tokenExpiryTime - Date.now()) / 60000); showStatusMessage(`✅ 세션이 ${minutes}분 연장되었습니다.`, 'success'); addLog(`✅ 세션 연장 완료 (${minutes}분)`, 'success'); } else { showView('blog-selection'); fetchUserBlogs(); } updateSearchQuotaDisplay(); } function handleLogout() { if (tokenCountdownInterval) { clearInterval(tokenCountdownInterval); } if (tokenRefreshTimer) { clearTimeout(tokenRefreshTimer); } if (tokenWarningTimer) { clearTimeout(tokenWarningTimer); } if (realtimeClockInterval) { clearInterval(realtimeClockInterval); } if (nextPostingTimeInterval) { clearInterval(nextPostingTimeInterval); } if (accessToken) { google.accounts.oauth2.revoke(accessToken, () => { accessToken = null; selectedBlogId = null; selectedBlogName = null; selectedBlogUrl = null; userBlogs = []; tokenExpiryTime = null; isRefreshing = false; showView('settings'); }); } } // API 호출 (Blogger) async function fetchWithAuth(url, options = {}) { if (tokenExpiryTime && Date.now() >= tokenExpiryTime - 60000) { console.log('토큰이 곧 만료됩니다. 갱신 중...'); await new Promise((resolve) => { extendSession(true); setTimeout(resolve, 2000); }); } if (!accessToken) throw new Error('인증 토큰이 없습니다.'); const headers = { ...options.headers, 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; try { const response = await fetch(url, { ...options, headers }); if (response.status === 401) { console.log('401 에러 감지, 토큰 갱신 시도...'); extendSession(true); await new Promise(resolve => setTimeout(resolve, 2000)); if (accessToken) { const retryHeaders = { ...options.headers, 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; const retryResponse = await fetch(url, { ...options, headers: retryHeaders }); if (retryResponse.status === 401) { handleLogout(); throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.'); } if (!retryResponse.ok) { const errorData = await retryResponse.json(); throw new Error(errorData.error.message || `HTTP error! status: ${retryResponse.status}`); } return retryResponse.json(); } } if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error.message || `HTTP error! status: ${response.status}`); } return response.json(); } catch (error) { console.error('API 요청 실패:', error); throw error; } } async function fetchUserBlogs() { const blogListLoader = document.getElementById('blog-list-loader'); const blogList = document.getElementById('blog-list'); if (blogListLoader) blogListLoader.style.display = 'flex'; if (blogList) blogList.innerHTML = ''; try { const data = await fetchWithAuth('https://www.googleapis.com/blogger/v3/users/self/blogs'); userBlogs = data.items || []; renderBlogList(userBlogs); } catch (error) { showStatusMessage(`블로그 목록 로드 실패: ${error.message}`, 'error'); const settingsErrorEl = document.getElementById('settings-error'); if (settingsErrorEl) { settingsErrorEl.textContent = `블로그 목록 로드 실패: ${error.message}`; settingsErrorEl.classList.remove('hidden'); } showView('settings'); } finally { if (blogListLoader) blogListLoader.style.display = 'none'; } } function renderBlogList(blogs) { const blogList = document.getElementById('blog-list'); if (!blogList) return; if (blogs.length === 0) { blogList.innerHTML = '
  • 소유한 블로그가 없습니다.
  • '; return; } blogList.innerHTML = blogs.map(blog => `
  • ${blog.name}

    ${blog.url}

  • `).join(''); } window.selectBlog = function(blogId, blogName, blogUrl) { selectedBlogId = blogId; selectedBlogName = blogName; selectedBlogUrl = blogUrl; const blogTitleEl = document.getElementById('blog-title'); if (blogTitleEl) { blogTitleEl.textContent = blogName; } const blogUrlText = document.getElementById('blog-url-text'); if (blogUrlText) { blogUrlText.textContent = blogUrl; } WRITING_INSTRUCTIONS = localStorage.getItem(`bloggerInstructions_${selectedBlogId}`) || defaultInstructions; const writingInstructionsInput = document.getElementById('writing-instructions-input'); if (writingInstructionsInput) { writingInstructionsInput.value = WRITING_INSTRUCTIONS; } showView('posting'); handleTabClick({ target: document.querySelector('[data-tab="internal-link-mode"]') }); updateSearchQuotaDisplay(); } function handleSaveInstructions() { const writingInstructionsInput = document.getElementById('writing-instructions-input'); const newInstructions = writingInstructionsInput?.value.trim(); if (!newInstructions) { showStatusMessage('지침 내용이 비어있습니다.', 'error'); return; } localStorage.setItem(`bloggerInstructions_${selectedBlogId}`, newInstructions); WRITING_INSTRUCTIONS = newInstructions; showStatusMessage('글쓰기 지침이 현재 블로그의 기본값으로 저장되었습니다.', 'success'); } // 예약된 글 제목 가져오기 (중복 체크용) async function fetchScheduledPostTitles() { try { const data = await fetchWithAuth( `https://www.googleapis.com/blogger/v3/blogs/${selectedBlogId}/posts?status=SCHEDULED&maxResults=500&fields=items(title)` ); scheduledPostTitles = new Set((data.items || []).map(post => post.title.toLowerCase())); return scheduledPostTitles; } catch (error) { console.error('예약된 글 목록 가져오기 실패:', error); return new Set(); } } // 라벨 처리 함수 (150자 강제 제한) function processLabels(labels) { if (!labels || labels.length === 0) return []; let processedLabels = labels.map(label => { const trimmed = label.trim(); return trimmed.length > 20 ? trimmed.substring(0, 20) : trimmed; }); let totalLength = processedLabels.join(',').length; while (totalLength > 150 && processedLabels.length > 1) { processedLabels.pop(); totalLength = processedLabels.join(',').length; } if (totalLength > 150 && processedLabels.length === 1) { const maxLength = 150; processedLabels[0] = processedLabels[0].substring(0, maxLength); } return processedLabels; } // Gemini API 호출 function cleanHtml(rawHtml) { const bodyMatch = rawHtml.match(/]*>([\s\S]*)<\/body>/i); let cleanedContent; if (bodyMatch && bodyMatch[1]) { cleanedContent = bodyMatch[1].trim(); } else { cleanedContent = rawHtml .replace(//i, '') .replace(/]*>/i, '') .replace(/<\/html>/i, '') .replace(/]*>[\s\S]*<\/head>/i, '') .replace(/```html/g, '') .replace(/```/g, '') .trim(); } cleanedContent = cleanedContent.replace(/]*>.*?<\/h1>/gi, ''); cleanedContent = cleanedContent.replace(/<(div|p|section|aside)[^>]*>\s*(태그|관련 태그|TAGS)\s*:[\s\S]*?<\/\1>/gi, ''); cleanedContent = cleanedContent.replace(/^\s*(태그|관련 태그|TAGS)\s*:.*$/gim, ''); cleanedContent = cleanedContent.replace(/^.+?<\/[hH][2-3]>/, '').trim(); return cleanedContent.trim(); } async function apiFetchWithRetry(url, payload) { const maxRetries = 3; let delay = 2000; for (let i = 0; i < maxRetries; i++) { if (!isProcessRunning) throw new Error('작업 중지됨'); if (isProcessPaused) { await new Promise(resolve => { const checkPause = setInterval(() => { if (!isProcessPaused) { clearInterval(checkPause); resolve(); } }, 1000); }); } try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const errorData = await response.json(); const errorMessage = errorData.error?.message || `HTTP error! status: ${response.status}`; if (response.status === 429 || response.status === 503 || errorMessage.toLowerCase().includes('overloaded') || errorMessage.toLowerCase().includes('internal error')) { throw new Error(`RETRYABLE: ${errorMessage}`); } throw new Error(`API 요청 실패: ${errorMessage}`); } return await response.json(); } catch (error) { if (error.message.startsWith('RETRYABLE') && i < maxRetries - 1) { addLog(`API 서버 오류 감지. ${delay / 1000}초 후 재시도... (${i + 1}/${maxRetries})`, 'warn'); await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; } else { const finalError = error.message.replace('RETRYABLE: ', ''); throw new Error(finalError); } } } } async function geminiFetch(model, prompt) { const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${GEMINI_API_KEY}`; const payload = { contents: [{ parts: [{ text: prompt }] }] }; const result = await apiFetchWithRetry(apiUrl, payload); if (!result.candidates || !result.candidates[0].content.parts[0].text) { throw new Error("API로부터 유효한 텍스트 응답을 받지 못했습니다."); } const rawText = result.candidates[0].content.parts[0].text; if (!prompt.includes("태그(라벨)를 콤마(,)로 구분하여")) { return cleanHtml(rawText); } return rawText.trim(); } // 예약된 글 목록 가져오기 async function fetchScheduledPosts() { const scheduledLoader = document.getElementById('scheduled-loader'); const scheduledListUl = document.getElementById('scheduled-list-ul'); const scheduledListTitle = document.getElementById('scheduled-list-title'); if (scheduledLoader) scheduledLoader.style.display = 'flex'; if (scheduledListUl) scheduledListUl.innerHTML = ''; if (scheduledListTitle) scheduledListTitle.textContent = '예약된 글 목록'; try { const data = await fetchWithAuth(`https://www.googleapis.com/blogger/v3/blogs/${selectedBlogId}/posts?status=SCHEDULED&orderBy=published&maxResults=100`); renderScheduledPosts(data.items || []); } catch (error) { if (scheduledListUl) { scheduledListUl.innerHTML = `
  • ${error.message}
  • `; } } finally { if (scheduledLoader) scheduledLoader.style.display = 'none'; } } function renderScheduledPosts(posts) { const titleEl = document.getElementById('scheduled-list-title'); const scheduledListUl = document.getElementById('scheduled-list-ul'); if (titleEl) { titleEl.textContent = `예약된 글 목록 (${posts.length}개)`; } const titleCounts = {}; posts.forEach(post => { const title = post.title.toLowerCase(); titleCounts[title] = (titleCounts[title] || 0) + 1; }); if (posts.length === 0) { if (scheduledListUl) { scheduledListUl.innerHTML = '
  • 예약된 글이 없습니다.
  • '; } return; } if (scheduledListUl) { scheduledListUl.innerHTML = posts.map(post => { const isDuplicate = titleCounts[post.title.toLowerCase()] > 1; const publishTime = new Date(post.published).toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); return `
  • ${post.title} ${isDuplicate ? '(중복)' : ''}

    📅 ${publishTime} 발행 예정

  • `; }).join(''); } } window.handleCancelSchedule = async function(postId) { if (!confirm('이 글의 예약을 취소하고 초안으로 되돌리시겠습니까?')) return; try { await fetchWithAuth(`https://www.googleapis.com/blogger/v3/blogs/${selectedBlogId}/posts/${postId}/revert`, { method: 'POST' }); showStatusMessage('예약이 성공적으로 취소되었습니다.', 'success'); fetchScheduledPosts(); } catch (error) { showStatusMessage(error.message, 'error'); } } // 수동 포스팅 함수들 function showManualPreview() { const manualContentInput = document.getElementById('manual-content-input'); const manualContentPreview = document.getElementById('manual-content-preview'); const manualPreviewButton = document.getElementById('manual-preview-button'); const manualCodeViewButton = document.getElementById('manual-code-view-button'); if (manualContentPreview && manualContentInput) { manualContentPreview.innerHTML = manualContentInput.value; manualContentInput.classList.add('hidden'); manualContentPreview.classList.remove('hidden'); } if (manualPreviewButton) manualPreviewButton.classList.add('hidden'); if (manualCodeViewButton) manualCodeViewButton.classList.remove('hidden'); } function showManualCodeView() { const manualContentInput = document.getElementById('manual-content-input'); const manualContentPreview = document.getElementById('manual-content-preview'); const manualPreviewButton = document.getElementById('manual-preview-button'); const manualCodeViewButton = document.getElementById('manual-code-view-button'); if (manualContentInput) manualContentInput.classList.remove('hidden'); if (manualContentPreview) manualContentPreview.classList.add('hidden'); if (manualPreviewButton) manualPreviewButton.classList.remove('hidden'); if (manualCodeViewButton) manualCodeViewButton.classList.add('hidden'); } function handleManualReset() { if (!confirm('수동 포스팅 양식의 모든 내용을 지우시겠습니까?')) return; const manualTitleInput = document.getElementById('manual-title-input'); const manualContentInput = document.getElementById('manual-content-input'); const manualLabelsInput = document.getElementById('manual-labels-input'); const manualPublishTimeInput = document.getElementById('manual-publish-time-input'); if (manualTitleInput) manualTitleInput.value = ''; if (manualContentInput) manualContentInput.value = ''; if (manualLabelsInput) manualLabelsInput.value = ''; if (manualPublishTimeInput) manualPublishTimeInput.value = ''; showManualCodeView(); } async function handleManualPublishPost() { const manualTitleInput = document.getElementById('manual-title-input'); const manualContentInput = document.getElementById('manual-content-input'); const manualLabelsInput = document.getElementById('manual-labels-input'); const manualPublishTimeInput = document.getElementById('manual-publish-time-input'); const progressContainer = document.getElementById('progress-container'); const title = manualTitleInput?.value.trim(); const content = manualContentInput?.value.trim(); const labelsText = manualLabelsInput?.value.trim(); const publishTime = manualPublishTimeInput?.value; if (!title || !content) { showStatusMessage('제목과 본문을 모두 입력해주세요.', 'error'); return; } const rawLabels = labelsText ? labelsText.split(',').map(tag => tag.trim()).filter(Boolean) : []; const processedLabels = processLabels(rawLabels); if (rawLabels.length !== processedLabels.length) { addLog(`⚠️ 라벨이 150자 제한으로 인해 ${rawLabels.length}개에서 ${processedLabels.length}개로 조정되었습니다.`, 'warn'); } const postData = { title: title, content: content, labels: processedLabels }; initLog(); if (progressContainer) progressContainer.classList.remove('hidden'); addLog(`📝 수동 포스팅 시작: "${title}"`, 'info'); if (postData.labels.length > 0) { const labelString = postData.labels.join(', '); addLog(`🏷️ 라벨 (${labelString.length}자): ${labelString}`, 'info'); } await publishPost(postData, publishTime); if (progressContainer) progressContainer.classList.add('hidden'); } // AI 포스팅 함수 async function createAIPost(topic, instructions, shouldGenerateImage, useGoogleSearch = false) { const steps = shouldGenerateImage ? 4 : 3; let currentStep = 0; // 각 포스팅마다 Google Search 실행 let searchResults = null; if (useGoogleSearch) { currentStep++; updateProgress((currentStep/steps) * 100, `단계 ${currentStep}/${steps}: Google 검색 중...`); addLog(`🔍 "${topic}"에 대한 최신 자료 검색 중...`, 'info'); searchResults = await searchGoogle(topic); if (searchResults) { addLog(`✅ 검색 완료! 최신 정보를 반영하여 글을 작성합니다`, 'success'); // 검색된 소스 표시 if (searchedSources && searchedSources.length > 0) { const sourcesList = searchedSources.map((s, i) => `${i+1}. ${s.domain}`).join(', '); addLog(`📚 참고 사이트: ${sourcesList}`, 'info'); } } else { addLog(`⚠️ 검색 결과가 없거나 한도 초과. 기본 AI 작성으로 진행`, 'warn'); } } currentStep++; updateProgress((currentStep/steps) * 100, `단계 ${currentStep}/${steps}: AI 글 작성`); addLog('📝 AI가 글을 작성하기 시작했습니다...', 'info'); let articlePrompt = ` **주제:** ${topic} **지침:** ${instructions} `; // 검색 결과가 있으면 프롬프트에 추가 if (searchResults) { articlePrompt = ` **주제:** ${topic} **참고할 최신 정보 (Google 검색 결과 ${SEARCH_RESULTS_COUNT}개):** ${searchResults} **지침:** ${instructions} 위의 최신 정보를 참고하여 더욱 정확하고 신뢰성 있는 글을 작성해주세요. 특히 날짜, 시간, 장소, 가격 등의 구체적인 정보가 있다면 반드시 포함시켜주세요. `; } let dots = 0; const writingInterval = setInterval(() => { if (isProcessPaused) { const currentStatusEl = document.getElementById('current-status'); if (currentStatusEl) { currentStatusEl.textContent = `AI 글 작성 일시정지됨`; } return; } dots = (dots + 1) % 4; const dotStr = '.'.repeat(dots); const elapsed = Math.floor((Date.now() - globalStartTime) / 1000); const currentStatusEl = document.getElementById('current-status'); if (currentStatusEl) { currentStatusEl.textContent = `AI가 글을 작성 중${dotStr} (${elapsed}초 경과)`; } }, 500); try { const articleContent = await geminiFetch('gemini-2.5-flash', articlePrompt); clearInterval(writingInterval); // 글자 수 계산 const textContent = articleContent.replace(/<[^>]*>/g, ''); const charCount = textContent.length; addLog(`✅ 글 작성 완료! (총 ${charCount.toLocaleString()}자)`, 'success'); let finalContent = articleContent; if(shouldGenerateImage) { const h2Regex = /]*>(.*?)<\/h2>/gi; const h2Matches = [...finalContent.matchAll(h2Regex)]; let imagePrompts = [topic]; if (h2Matches.length >= 1) { imagePrompts.push(h2Matches[Math.floor(h2Matches.length / 2)][1].trim()); } const imageUrls = []; for (let i = 0; i < imagePrompts.length; i++) { const p = imagePrompts[i]; if (!isProcessRunning) throw new Error('작업 중지됨'); if (isProcessPaused) { await new Promise(resolve => { const checkPause = setInterval(() => { if (!isProcessPaused) { clearInterval(checkPause); resolve(); } }, 1000); }); } currentStep++; updateProgress((currentStep/steps) * 100, `단계 ${currentStep}/${steps}: 이미지 ${i+1}/${imagePrompts.length} 생성`); addLog(`🎨 flux AI 이미지 생성 시작: "${p.substring(0, 30)}..."`, 'info'); const imageUrl = await generatePollinationsImage(p); imageUrls.push({ url: imageUrl, alt: p }); addLog(`✅ 이미지 생성 완료!`, 'success'); } addLog('🖼️ 본문에 이미지 삽입 중...', 'info'); let imageTags = imageUrls.map(img => { const altText = img.alt.replace(/"/g, '"'); return `
    ${altText}
    ${altText}
    `; }); if (imageTags.length > 0) { const separator = ''; const paragraphs = finalContent.split(/(<\/p>)/); const assembledParts = []; for (let i = 0; i < paragraphs.length; i += 2) { assembledParts.push(paragraphs[i] + (paragraphs[i + 1] || '')); } if (assembledParts.length > 0) { const thumbnailTag = imageTags.shift(); assembledParts[0] = assembledParts[0] + `\n${thumbnailTag}\n${separator}`; } if (imageTags.length > 0 && assembledParts.length > 2) { const secondImageTag = imageTags.shift(); const midPoint = Math.floor(assembledParts.length / 2); assembledParts.splice(midPoint, 0, secondImageTag); } finalContent = assembledParts.join(''); } addLog(`✅ ${imageUrls.length}개 이미지 삽입 완료`, 'success'); } currentStep++; updateProgress((currentStep/steps) * 100, `단계 ${currentStep}/${steps}: SEO 태그 생성`); addLog('🏷️ AI가 SEO 태그를 생성 중...', 'info'); const tagsPrompt = `당신은 블로그의 SEO 전문가입니다. 다음 글의 주제를 분석하여, **가장 관련성이 높고 검색 노출에 유리한** 키워드 태그(라벨)를 5개에서 7개 사이로 생성해주세요. **중요:** - 각 태그는 최대 15자 이내로 짧고 간결하게 - '${topic}' 주제와 직접적으로 관련된 키워드만 포함 - 추천, 1등, 1위, 최고, 베스트, TOP, BEST 등 약관 위반 단어 절대 사용 금지 - 결과는 콤마(,)로 구분된 키워드 목록으로만 제공 주제: ${topic}`; const tagsText = await geminiFetch('gemini-2.5-flash', tagsPrompt); let labels = tagsText.split(',').map(tag => tag.trim()).filter(Boolean); // 약관 위반 단어 필터링 const prohibitedWords = ['추천', '1등', '1위', '최고', '베스트', 'BEST', 'TOP']; labels = labels.filter(label => { for (const word of prohibitedWords) { if (label.includes(word)) { return false; } } return true; }); const processedLabels = processLabels(labels); if (labels.length !== processedLabels.length) { addLog(`⚠️ 라벨이 150자 제한으로 ${labels.length}개에서 ${processedLabels.length}개로 조정됨`, 'warn'); } const labelString = processedLabels.join(', '); addLog(`✅ SEO 태그 ${processedLabels.length}개 생성 완료 (${labelString.length}자)`, 'success'); updateProgress(100, `✅ 모든 콘텐츠 생성 완료!`); const currentStatusEl = document.getElementById('current-status'); if (currentStatusEl) { currentStatusEl.textContent = '모든 콘텐츠 생성 완료!'; } const postData = { title: topic, content: finalContent, labels: processedLabels }; return postData; } catch (error) { clearInterval(writingInterval); throw error; } } async function handleAIPublishPost() { const postTopicInput = document.getElementById('post-topic-input'); const aiPublishTimeInput = document.getElementById('ai-publish-time-input'); const writingInstructionsInput = document.getElementById('writing-instructions-input'); const generateImageCheckboxSingle = document.getElementById('generate-image-checkbox-single'); const useGoogleSearchSingle = document.getElementById('use-google-search-single'); const singleSearchResultPreview = document.getElementById('single-search-result-preview'); const searchSourcesContainer = document.getElementById('search-sources-container'); const progressContainer = document.getElementById('progress-container'); const progressBar = document.getElementById('progress-bar'); const timerContainer = document.getElementById('timer-container'); const currentStatusEl = document.getElementById('current-status'); const topic = postTopicInput?.value.trim(); const publishTime = aiPublishTimeInput?.value; const instructions = writingInstructionsInput?.value.trim(); const shouldGenerateImage = generateImageCheckboxSingle?.checked; const useGoogleSearch = useGoogleSearchSingle?.checked; if (!topic) { showStatusMessage('글 주제를 입력해주세요.', 'error'); return; } if (!instructions) { showStatusMessage('글쓰기 지침을 입력해주세요.', 'error'); return; } if (publishTime) { await fetchScheduledPostTitles(); if (scheduledPostTitles.has(topic.toLowerCase())) { if (!confirm(`"${topic}" 제목의 글이 이미 예약되어 있습니다.\n그래도 계속하시겠습니까?`)) { return; } } } handleSaveInstructions(); isProcessRunning = true; setPublishButtonState(true, 'single'); initLog(); if (progressContainer) progressContainer.classList.remove('hidden'); searchedSources = []; const ESTIMATED_TIME = { searchTime: useGoogleSearch ? 5 : 0, contentGeneration: 20, imageGeneration: shouldGenerateImage ? 15 : 0, tagGeneration: 10, publishing: 5 }; const TOTAL_TIME = Object.values(ESTIMATED_TIME).reduce((a, b) => a + b, 0); globalStartTime = Date.now(); let elapsed = 0; const timerInterval = setInterval(() => { elapsed = Math.floor((Date.now() - globalStartTime) / 1000); const remaining = Math.max(0, TOTAL_TIME - elapsed); if (timerContainer) { if (remaining > 0) { timerContainer.innerHTML = `⏱️ 예상 남은 시간: ${remaining}초 (경과: ${elapsed}초)`; } else { timerContainer.innerHTML = `⏳ 거의 완료되었습니다... (${elapsed}초 경과)`; } } }, 1000); try { addLog('🚀 AI 포스팅 프로세스 시작', 'info'); addLog(`📋 주제: "${topic}"`, 'info'); addLog(`🖼️ 이미지 생성: ${shouldGenerateImage ? 'flux 사용' : '아니오'}`, 'info'); addLog(`🔍 Google 검색: ${useGoogleSearch ? '사용' : '사용 안 함'}`, 'info'); if (currentStatusEl) { currentStatusEl.textContent = 'AI 포스팅 프로세스 시작...'; } const postData = await createAIPost(topic, instructions, shouldGenerateImage, useGoogleSearch); addLog('📤 블로그에 발행 중...', 'info'); updateProgress(95, '블로그에 글을 발행하는 중...'); if (currentStatusEl) { currentStatusEl.textContent = '블로그에 글을 발행하는 중...'; } await publishPost(postData, publishTime); if (publishTime) { scheduledPostTitles.add(topic.toLowerCase()); } updateProgress(100, '✅ 완료!'); if (currentStatusEl) { currentStatusEl.textContent = '포스팅 완료!'; } if (postTopicInput) postTopicInput.value = ''; if (singleSearchResultPreview) { singleSearchResultPreview.innerHTML = ''; singleSearchResultPreview.classList.add('hidden'); } if (searchSourcesContainer) { searchSourcesContainer.innerHTML = ''; searchSourcesContainer.classList.add('hidden'); } searchedSources = []; clearInterval(timerInterval); const totalElapsed = Math.floor((Date.now() - globalStartTime) / 1000); if (timerContainer) { timerContainer.innerHTML = `✅ 완료! (총 소요시간: ${totalElapsed}초)`; } addLog(`🎉 총 ${totalElapsed}초 만에 포스팅 완료!`, 'success'); if (progressBar) { progressBar.classList.remove('error'); progressBar.classList.add('success'); } } catch (error) { clearInterval(timerInterval); handlePostingError(error); if (progressBar) { progressBar.classList.remove('success'); progressBar.classList.add('error'); } } finally { isProcessRunning = false; setPublishButtonState(false, 'single'); } } // 수정된 대량 예약 함수 async function handleBulkSchedule() { const topicIdeasList = document.getElementById('topic-ideas-list'); const scheduleTimesInput = document.getElementById('schedule-times-input'); const scheduleDaysInput = document.getElementById('schedule-days-input'); const scheduleDelayInput = document.getElementById('schedule-delay-input'); const generateImageCheckboxBulk = document.getElementById('generate-image-checkbox-bulk'); const useGoogleSearchBulk = document.getElementById('use-google-search-bulk'); const bulkStartDateInput = document.getElementById('bulk-start-date-input'); const progressContainer = document.getElementById('progress-container'); if (!topicIdeasList) return; const selectedCheckboxes = Array.from(topicIdeasList.querySelectorAll('input[type="checkbox"]:checked')); // 수정된 부분: 각 체크된 항목의 topic-title-input 값 사용 let selectedTopics = selectedCheckboxes.map(cb => { const li = cb.closest('li'); const titleInput = li ? li.querySelector('.topic-title-input') : null; const manualTitle = titleInput ? titleInput.value.trim() : ''; return manualTitle || cb.value; }); const timesText = scheduleTimesInput?.value.trim() || ''; const daysValue = scheduleDaysInput?.value.trim() || ''; const delayMinutes = parseInt(scheduleDelayInput?.value, 10); const shouldGenerateImages = generateImageCheckboxBulk?.checked; const useGoogleSearch = useGoogleSearchBulk?.checked; const startDate = bulkStartDateInput?.value; if (selectedTopics.length === 0) { showStatusMessage('예약할 주제를 하나 이상 선택해주세요.', 'error'); return; } const times = timesText.split(',').map(t => t.trim()).filter(Boolean); if (times.length === 0 || !times.every(t => /^\d{1,2}:\d{2}$/.test(t))) { showStatusMessage('발행 시간을 "HH:MM" 형식으로 하나 이상 입력해주세요. (예: 09:00, 17:00)', 'error'); return; } const days = parseInt(daysValue, 10); if (isNaN(days) || days < 1) { showStatusMessage('예약 기간은 1일 이상이어야 합니다.', 'error'); return; } if (isNaN(delayMinutes) || delayMinutes < 1) { showStatusMessage('대기 시간은 1분 이상이어야 합니다.', 'error'); return; } if (!startDate) { showStatusMessage('시작 날짜를 선택해주세요.', 'error'); return; } const postsPerDay = times.length; const totalSlots = postsPerDay * days; const exactDaysNeeded = Math.ceil(selectedTopics.length / postsPerDay); if (totalSlots !== selectedTopics.length) { let errorMessage = ''; if (totalSlots < selectedTopics.length) { const shortage = selectedTopics.length - totalSlots; errorMessage = `❌ 선택한 ${selectedTopics.length}개 주제를 모두 예약하려면 정확히 ${exactDaysNeeded}일이 필요합니다.\n\n현재 ${days}일 설정:\n• 하루 ${postsPerDay}회 × ${days}일 = ${totalSlots}개 슬롯\n• 부족한 슬롯: ${shortage}개\n\n${exactDaysNeeded}일로 수정하시겠습니까?`; } else { const excess = totalSlots - selectedTopics.length; errorMessage = `❌ 선택한 ${selectedTopics.length}개 주제는 정확히 ${exactDaysNeeded}일이면 충분합니다.\n\n현재 ${days}일 설정:\n• 하루 ${postsPerDay}회 × ${days}일 = ${totalSlots}개 슬롯\n• 초과 슬롯: ${excess}개\n\n${exactDaysNeeded}일로 수정하시겠습니까?`; } if (confirm(errorMessage)) { if (scheduleDaysInput) { scheduleDaysInput.value = exactDaysNeeded; } validateScheduleSettings(); updateBulkDateInfo(); return; } return; } showStatusMessage('기존 예약 현황을 확인하는 중...', 'info'); const scheduleResult = await createSmartSchedule(selectedTopics, times, days, shouldGenerateImages, useGoogleSearch, startDate); const { bulkQueue, skippedSlots, processedTopics } = scheduleResult; if (skippedSlots.length > 0) { let skipMessage = `⚠️ 다음 시간대는 자동으로 건너뛰었습니다:\n\n`; const groupedSkips = {}; skippedSlots.forEach(skip => { const key = skip.reason; if (!groupedSkips[key]) groupedSkips[key] = []; groupedSkips[key].push(`${skip.date} ${skip.time}`); }); Object.keys(groupedSkips).forEach(reason => { skipMessage += `📅 ${reason}:\n`; groupedSkips[reason].slice(0, 5).forEach(timeSlot => { skipMessage += ` • ${timeSlot}\n`; }); if (groupedSkips[reason].length > 5) { skipMessage += ` • ... 외 ${groupedSkips[reason].length - 5}개\n`; } skipMessage += `\n`; }); if (bulkQueue.length > 0) { const firstJob = bulkQueue[0]; const nextAvailable = firstJob.publishDate.toLocaleString('ko-KR'); skipMessage += `✅ 다음 예약 가능한 시간: ${nextAvailable}\n`; skipMessage += `📝 실제 예약될 글: ${bulkQueue.length}개 (${selectedTopics.length}개 중)\n\n`; skipMessage += `🤖 스마트 스케줄링이 충돌을 자동으로 해결했습니다!\n`; } else { skipMessage += `❌ 예약 가능한 시간이 없습니다.\n`; showStatusMessage(skipMessage, 'error'); return; } skipMessage += `계속하시겠습니까?`; if (!confirm(skipMessage)) { return; } } const duplicateTopics = bulkQueue.map(job => job.topic).filter(topic => scheduledPostTitles.has(topic.toLowerCase()) ); if (duplicateTopics.length > 0) { const duplicateList = duplicateTopics.join('\n- '); if (!confirm(`다음 주제들은 이미 예약된 제목과 중복됩니다:\n\n- ${duplicateList}\n\n중복된 주제를 제외하고 계속하시겠습니까?`)) { return; } bulkScheduleQueue = bulkQueue.filter(job => !scheduledPostTitles.has(job.topic.toLowerCase()) ); if (bulkScheduleQueue.length === 0) { showStatusMessage('예약 가능한 새로운 주제가 없습니다.', 'error'); return; } } else { bulkScheduleQueue = bulkQueue; } totalBulkJobs = bulkScheduleQueue.length; completedBulkJobs = 0; const firstJob = bulkScheduleQueue[0]; const lastJob = bulkScheduleQueue[bulkScheduleQueue.length - 1]; const startDateFormatted = new Date(startDate).toLocaleDateString('ko-KR'); const confirmMessage = ` 📅 스마트 대량 예약 상세 정보: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ • 시작 날짜: ${startDateFormatted} • 선택한 주제: ${selectedTopics.length}개 • 하루 발행 횟수: ${postsPerDay}회 (${times.join(', ')}) • 예약 기간: ${days}일 • 건너뛴 시간: ${skippedSlots.length}개 (충돌/과거) • 실제 예약될 글: ${totalBulkJobs}개 • 첫 발행: ${firstJob.publishDate.toLocaleString('ko-KR')} • 마지막 발행: ${lastJob.publishDate.toLocaleString('ko-KR')} • 작업 간 대기: ${delayMinutes}분 • 이미지 생성: ${shouldGenerateImages ? '예' : '아니오'} • Google 검색: ${useGoogleSearch ? '사용 (각 포스팅마다 자료 조사)' : '사용 안 함'} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ${useGoogleSearch ? '🔍 각 포스팅마다 Google Search API로 최신 자료를 조사합니다.\n' : ''} 🧠 스마트 스케줄링으로 기존 예약과의 충돌을 자동으로 해결했습니다! 이 작업은 완료까지 약 ${Math.ceil(totalBulkJobs * delayMinutes / 60)}시간이 소요될 수 있습니다. 계속하시겠습니까?`; if (!confirm(confirmMessage)) return; isProcessRunning = true; initLog(); if (progressContainer) progressContainer.classList.remove('hidden'); setPublishButtonState(true, 'bulk'); globalStartTime = Date.now(); updateQueueStatus(); addLog(`🚀 스마트 대량 예약 작업 시작 (총 ${totalBulkJobs}개)`, 'info'); addLog(`📅 시작 날짜: ${startDateFormatted}`, 'info'); if (skippedSlots.length > 0) { addLog(`🧠 ${skippedSlots.length}개 시간대를 자동으로 건너뛰었습니다`, 'info'); } if (useGoogleSearch) { addLog(`🔍 각 포스팅마다 Google 검색으로 최신 정보를 조사합니다`, 'info'); addLog(`📊 현재 남은 검색 횟수: ${SEARCH_DAILY_LIMIT - searchUsageToday.count}회`, 'info'); } processBulkScheduleQueue(); } // 대량 작업 큐 처리 async function processBulkScheduleQueue() { if (!isProcessRunning || bulkScheduleQueue.length === 0) { if (isProcessRunning && completedBulkJobs === totalBulkJobs) { updateProgress(100, `✅ 모든 대량 예약 작업 완료! (${totalBulkJobs}/${totalBulkJobs})`); showStatusMessage(`🎉 ${totalBulkJobs}개의 스마트 대량 예약이 완료되었습니다!`, 'success'); const totalTime = Math.floor((Date.now() - globalStartTime) / 1000); const hours = Math.floor(totalTime / 3600); const minutes = Math.floor((totalTime % 3600) / 60); const seconds = totalTime % 60; addLog(`⏱️ 총 소요시간: ${hours}시간 ${minutes}분 ${seconds}초`, 'success'); } setPublishButtonState(false, 'bulk'); isProcessRunning = false; totalBulkJobs = 0; completedBulkJobs = 0; updateQueueStatus(); return; } if (isProcessPaused) { pausedQueue = [...bulkScheduleQueue]; bulkScheduleQueue = []; addLog('⏸️ 대량 작업이 일시정지되었습니다', 'warn'); return; } const job = bulkScheduleQueue.shift(); completedBulkJobs++; const publishTimeStr = job.publishDate.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); updateProgress((completedBulkJobs / totalBulkJobs) * 100, `📝 (${completedBulkJobs}/${totalBulkJobs}) "${job.topic}" 작업 중...`); updateQueueStatus(); addLog(`📅 예약 시간: ${publishTimeStr}`, 'info'); if (job.useGoogleSearch) { addLog(`🔍 이 포스팅은 Google 검색을 사용합니다`, 'info'); } try { const postData = await createAIPost(job.topic, WRITING_INSTRUCTIONS, job.generateImage, job.useGoogleSearch); await publishPost(postData, job.publishDate.toISOString()); scheduledPostTitles.add(job.topic.toLowerCase()); addLog(`✅ "${job.topic}" 예약 성공! (${publishTimeStr})`, 'success'); } catch (e) { if (e.message === '작업 중지됨') { addLog(`🛑 작업이 중지되었습니다`, 'error'); return; } addLog(`❌ "${job.topic}" 예약 실패: ${e.message}`, 'error'); } finally { if (isProcessRunning && bulkScheduleQueue.length > 0) { const scheduleDelayInput = document.getElementById('schedule-delay-input'); const delayMinutes = parseInt(scheduleDelayInput?.value, 10) || 5; const delayMs = delayMinutes * 60 * 1000; const nextJob = bulkScheduleQueue[0]; const nextTimeStr = nextJob.publishDate.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); addLog(`⏳ ${delayMinutes}분 후 다음 작업 시작 (다음: "${nextJob.topic}" - ${nextTimeStr})`, 'info'); const waitStartTime = Date.now(); waitingInterval = setInterval(() => { const elapsed = Date.now() - waitStartTime; const remaining = Math.max(0, delayMs - elapsed); if (!isProcessRunning) { clearInterval(waitingInterval); addLog('대기 중 작업이 중지되었습니다', 'warn'); return; } if (isProcessPaused) { clearInterval(waitingInterval); pausedQueue = [...bulkScheduleQueue]; bulkScheduleQueue = []; addLog('대기 중 작업이 일시정지되었습니다', 'warn'); return; } if (remaining <= 0) { clearInterval(waitingInterval); processBulkScheduleQueue(); } else { const remainingSeconds = Math.ceil(remaining / 1000); const remainingMinutes = Math.floor(remainingSeconds / 60); const remainingSecondsOnly = remainingSeconds % 60; const currentStatusEl = document.getElementById('current-status'); if (currentStatusEl) { currentStatusEl.innerHTML = `⏱️ 다음 작업까지: ${remainingMinutes}분 ${remainingSecondsOnly}초`; } if (remainingSeconds % 10 === 0) { const timerContainer = document.getElementById('timer-container'); if (timerContainer) { timerContainer.innerHTML = `⏳ 대기 시간: ${remainingMinutes}분 ${remainingSecondsOnly}초 남음`; } } } }, 1000); } else { processBulkScheduleQueue(); } } } // 포스트 발행 async function publishPost(postData, publishTime) { const finalPostData = { ...postData }; const isScheduled = !!publishTime; const url = `https://www.googleapis.com/blogger/v3/blogs/${selectedBlogId}/posts/`; if (isScheduled) { const scheduledDate = new Date(publishTime); if (scheduledDate > new Date()) { finalPostData.published = scheduledDate.toISOString(); const timeStr = scheduledDate.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); addLog(`📅 예약 발행 시간: ${timeStr}`, 'info'); } } try { const result = await fetchWithAuth(url, { method: 'POST', body: JSON.stringify(finalPostData) }); const message = (isScheduled && new Date(finalPostData.published) > new Date()) ? '✅ 성공적으로 예약되었습니다!' : '✅ 성공적으로 발행되었습니다!'; if (!isProcessRunning || bulkScheduleQueue.length === 0) { showStatusMessage(`${message} 여기서 확인`, 'success'); } addLog(message, 'success'); } catch (error) { if (!isProcessRunning || bulkScheduleQueue.length === 0) { showStatusMessage(`발행 실패: ${error.message}`, 'error'); } addLog(`❌ 발행 실패: ${error.message}`, 'error'); throw error; } } // UI/UX 함수들 function setPublishButtonState(isLoading, mode) { const generateTopicsButton = document.getElementById('generate-topics-button'); const startBulkScheduleButton = document.getElementById('start-bulk-schedule-button'); const aiPublishButton = document.getElementById('ai-publish-button'); const stopButton = document.getElementById('stop-button'); const progressContainer = document.getElementById('progress-container'); const bulkButtons = [generateTopicsButton, startBulkScheduleButton]; const singleButton = aiPublishButton; if (isLoading) { if (stopButton) stopButton.classList.remove('hidden'); if (progressContainer) progressContainer.classList.remove('hidden'); if (mode === 'bulk') { bulkButtons.forEach(btn => { if (btn) btn.disabled = true; }); } else { if (singleButton) singleButton.disabled = true; } } else { if (stopButton) stopButton.classList.add('hidden'); const resumeButton = document.getElementById('resume-button'); if (resumeButton) resumeButton.classList.add('hidden'); bulkButtons.forEach(btn => { if (btn) btn.disabled = false; }); if (singleButton) singleButton.disabled = false; } } function showStatusMessage(message, type) { const statusMessageEl = document.getElementById('status-message'); if (!statusMessageEl) return; statusMessageEl.innerHTML = message; statusMessageEl.className = 'mt-4 p-4 rounded-md text-center'; const typeClasses = { success: ['bg-green-100', 'text-green-800'], error: ['bg-red-100', 'text-red-800'], info: ['bg-blue-100', 'text-blue-800'], warn: ['bg-yellow-100', 'text-yellow-800'] }; statusMessageEl.classList.add(...(typeClasses[type] || typeClasses.info)); statusMessageEl.classList.remove('hidden'); setTimeout(() => { statusMessageEl.classList.add('hidden'); }, 3000); } function initLog() { const logList = document.getElementById('log-list'); const progressBar = document.getElementById('progress-bar'); const statusMessageEl = document.getElementById('status-message'); const currentStatusEl = document.getElementById('current-status'); const timerContainer = document.getElementById('timer-container'); if (logList) logList.innerHTML = ''; if (progressBar) { progressBar.style.width = '0%'; progressBar.classList.remove('success', 'error', 'paused'); } if (statusMessageEl) statusMessageEl.classList.add('hidden'); if (currentStatusEl) { currentStatusEl.textContent = '준비 중...'; currentStatusEl.classList.remove('paused-indicator'); } if (timerContainer) timerContainer.textContent = ''; } function addLog(message, type = 'info') { const logList = document.getElementById('log-list'); const logContainer = document.getElementById('log-container'); if (!logList) return; const li = document.createElement('li'); const now = new Date(); const time = now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); let icon = ''; let typeClass = ''; if (type === 'success') { typeClass = 'log-success'; icon = '✅'; } else if (type === 'error') { typeClass = 'log-error'; icon = '❌'; } else if (type === 'warn') { typeClass = 'log-warn'; icon = '⚠️'; } else if (type === 'info') { typeClass = 'log-info'; icon = '📌'; } li.innerHTML = `[${time}] ${message}`; logList.appendChild(li); if (logContainer) { logContainer.scrollTop = logContainer.scrollHeight; } return li; } function handlePostingError(error) { console.error('포스팅 실패:', error); const currentStatusEl = document.getElementById('current-status'); if (currentStatusEl) { currentStatusEl.textContent = '작업 실패'; currentStatusEl.classList.remove('paused-indicator'); } if (error.message === '작업 중지됨') { showStatusMessage('작업이 사용자에 의해 중지되었습니다.', 'error'); addLog('작업이 중지되었습니다.', 'error'); } else { showStatusMessage(`발행 실패: ${error.message}`, 'error'); addLog(`발행 실패: ${error.message}`, 'error'); } } function updateProgress(percentage, message) { const currentStatusEl = document.getElementById('current-status'); const progressBar = document.getElementById('progress-bar'); if (message) { if (currentStatusEl) currentStatusEl.textContent = message; addLog(message); } if (progressBar) progressBar.style.width = `${percentage}%`; } // 이벤트 리스너 등록 document.addEventListener('DOMContentLoaded', () => { loadSettings(); loadSearchUsage(); setDefaultDates(); // 설정 저장 버튼 const saveSettingsButton = document.getElementById('save-settings-button'); if (saveSettingsButton) { saveSettingsButton.addEventListener('click', handleSaveSettings); } // Google 로그인 버튼 const googleLoginButton = document.getElementById('google-login-button'); if (googleLoginButton) { googleLoginButton.addEventListener('click', () => { if (tokenClient) { tokenClient.requestAccessToken(); } else { showStatusMessage('Google 로그인이 아직 초기화되지 않았습니다. 잠시 후 다시 시도해주세요.', 'warn'); } }); } // 로그아웃 버튼들 const logoutButtons = document.querySelectorAll('[id^="logout-button"]'); logoutButtons.forEach(btn => { btn.addEventListener('click', handleLogout); }); // 블로그 변경 버튼 const changeBlogBtn = document.getElementById('change-blog-btn'); if (changeBlogBtn) { changeBlogBtn.addEventListener('click', showBlogSelectionModal); } // 탭 전환 const tabs = document.getElementById('tabs'); if (tabs) { tabs.addEventListener('click', handleTabClick); } // RSS 가져오기 버튼 const fetchRssButton = document.getElementById('fetch-rss-button'); if (fetchRssButton) { fetchRssButton.addEventListener('click', handleFetchRss); } // 멀티 블로그 RSS 가져오기 const fetchMultiRssButton = document.getElementById('fetch-multi-rss-button'); if (fetchMultiRssButton) { fetchMultiRssButton.addEventListener('click', handleFetchMultiRss); } // 내부링크 포스팅 생성 const generateInternalLinkButton = document.getElementById('generate-internal-link-button'); if (generateInternalLinkButton) { generateInternalLinkButton.addEventListener('click', handleGenerateInternalLink); } // 주제 생성 버튼 const generateTopicsButton = document.getElementById('generate-topics-button'); if (generateTopicsButton) { generateTopicsButton.addEventListener('click', handleGenerateTopics); } // 전체 선택/해제 const selectAllTopicsButton = document.getElementById('select-all-topics-button'); if (selectAllTopicsButton) { selectAllTopicsButton.addEventListener('click', handleSelectAllTopics); } const deselectAllTopicsButton = document.getElementById('deselect-all-topics-button'); if (deselectAllTopicsButton) { deselectAllTopicsButton.addEventListener('click', handleDeselectAllTopics); } // 예약 설정 변경 감지 const scheduleTimesInput = document.getElementById('schedule-times-input'); const scheduleDaysInput = document.getElementById('schedule-days-input'); const bulkStartDateInput = document.getElementById('bulk-start-date-input'); if (scheduleTimesInput) { scheduleTimesInput.addEventListener('input', () => { validateScheduleSettings(); updateBulkDateInfo(); }); } if (scheduleDaysInput) { scheduleDaysInput.addEventListener('input', () => { validateScheduleSettings(); updateBulkDateInfo(); }); } if (bulkStartDateInput) { bulkStartDateInput.addEventListener('change', () => { validateScheduleSettings(); updateBulkDateInfo(); }); } // Google Search 토글 const useGoogleSearchBulk = document.getElementById('use-google-search-bulk'); if (useGoogleSearchBulk) { useGoogleSearchBulk.addEventListener('change', () => { const bulkSearchInfo = document.getElementById('bulk-search-info'); if (bulkSearchInfo) { if (useGoogleSearchBulk.checked) { bulkSearchInfo.classList.remove('hidden'); } else { bulkSearchInfo.classList.add('hidden'); } } updateSearchQuotaDisplay(); }); } const useGoogleSearchSingle = document.getElementById('use-google-search-single'); if (useGoogleSearchSingle) { useGoogleSearchSingle.addEventListener('change', () => { const singleSearchResultPreview = document.getElementById('single-search-result-preview'); if (singleSearchResultPreview) { if (useGoogleSearchSingle.checked) { singleSearchResultPreview.classList.remove('hidden'); } else { singleSearchResultPreview.classList.add('hidden'); } } }); } // 예약 미리보기 const previewScheduleButton = document.getElementById('preview-schedule-button'); if (previewScheduleButton) { previewScheduleButton.addEventListener('click', handlePreviewSchedule); } // 대량 예약 시작 const startBulkScheduleButton = document.getElementById('start-bulk-schedule-button'); if (startBulkScheduleButton) { startBulkScheduleButton.addEventListener('click', handleBulkSchedule); } // 단일 AI 포스팅 const aiPublishButton = document.getElementById('ai-publish-button'); if (aiPublishButton) { aiPublishButton.addEventListener('click', handleAIPublishPost); } // 글쓰기 지침 저장 const saveInstructionsButton = document.getElementById('save-instructions-button'); if (saveInstructionsButton) { saveInstructionsButton.addEventListener('click', handleSaveInstructions); } // 수동 포스팅 const manualPublishButton = document.getElementById('manual-publish-button'); if (manualPublishButton) { manualPublishButton.addEventListener('click', handleManualPublishPost); } const manualResetButton = document.getElementById('manual-reset-button'); if (manualResetButton) { manualResetButton.addEventListener('click', handleManualReset); } const manualPreviewButton = document.getElementById('manual-preview-button'); if (manualPreviewButton) { manualPreviewButton.addEventListener('click', showManualPreview); } const manualCodeViewButton = document.getElementById('manual-code-view-button'); if (manualCodeViewButton) { manualCodeViewButton.addEventListener('click', showManualCodeView); } // 작업 제어 const stopButton = document.getElementById('stop-button'); if (stopButton) { stopButton.addEventListener('click', handleStopProcess); } const resumeButton = document.getElementById('resume-button'); if (resumeButton) { resumeButton.addEventListener('click', handleResumeProcess); } const pauseResumeBtn = document.getElementById('pause-resume-btn'); if (pauseResumeBtn) { pauseResumeBtn.addEventListener('click', handlePauseResume); } const clearQueueBtn = document.getElementById('clear-queue-btn'); if (clearQueueBtn) { clearQueueBtn.addEventListener('click', handleClearQueue); } // 토큰 자동 갱신 토글 const autoRefreshToggle = document.getElementById('auto-refresh-toggle'); if (autoRefreshToggle) { const savedAutoRefresh = localStorage.getItem('autoRefreshToken'); if (savedAutoRefresh === 'true') { autoRefreshToggle.checked = true; isAutoRefreshEnabled = true; } autoRefreshToggle.addEventListener('change', handleAutoRefreshToggle); updateAutoRefreshStatus(); } // 수동 토큰 갱신 버튼 const manualRefreshBtn = document.getElementById('manual-refresh-btn'); if (manualRefreshBtn) { manualRefreshBtn.addEventListener('click', () => extendSession(false)); } // 비밀번호 표시/숨김 토글 document.querySelectorAll('[data-toggle-for]').forEach(button => { button.addEventListener('click', () => { const inputId = button.getAttribute('data-toggle-for'); const input = document.getElementById(inputId); if (input) { if (input.type === 'password') { input.type = 'text'; button.querySelector('i').setAttribute('data-lucide', 'eye-off'); } else { input.type = 'password'; button.querySelector('i').setAttribute('data-lucide', 'eye'); } lucide.createIcons(); } }); }); // 페이지 가시성 변경 감지 document.addEventListener('visibilitychange', handleVisibilityChange); // 실시간 시계 시작 startRealtimeClock(); // Lucide 아이콘 초기화 lucide.createIcons(); // Google API 로드 대기 const checkGoogleApi = setInterval(() => { if (window.google && window.google.accounts && CLIENT_ID) { clearInterval(checkGoogleApi); initializeGsi(); } }, 100); });